From ea97bba219935df092101f91aedfbb4483f1b572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 25 Jul 2026 23:20:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=81=D0=BC=D1=81-=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82):=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8/=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE-=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20+=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20+=20=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D0=BA=D0=BE=D1=88?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client_sms_senders (жизненный цикл имени) и client_sms_auto_rule (авто-рассылка) — RLS tenant-таблицы; гварды грантов для crm_supplier_worker/crm_admin_user (cross-tenant джоб и админ-подтверждение). Грант записи в ad_wallets для crm_admin_user (approve списывает/снимает заморозку). CHANGELOG v8.97/v8.98 с deploy-note про ре-ран srv_bypass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Models/ClientSmsAutoRule.php | 36 +++++++ app/app/Models/ClientSmsSender.php | 61 ++++++++++++ ...07_28_100000_create_client_sms_senders.php | 55 +++++++++++ ..._28_100100_create_client_sms_auto_rule.php | 45 +++++++++ ..._07_28_100200_grant_admin_wallet_write.php | 48 ++++++++++ app/tests/Feature/ClientSms/MigrationTest.php | 16 ++++ .../Feature/ClientSms/SenderModelTest.php | 94 +++++++++++++++++++ db/CHANGELOG_schema.md | 73 ++++++++++++++ 8 files changed, 428 insertions(+) create mode 100644 app/app/Models/ClientSmsAutoRule.php create mode 100644 app/app/Models/ClientSmsSender.php create mode 100644 app/database/migrations/2026_07_28_100000_create_client_sms_senders.php create mode 100644 app/database/migrations/2026_07_28_100100_create_client_sms_auto_rule.php create mode 100644 app/database/migrations/2026_07_28_100200_grant_admin_wallet_write.php create mode 100644 app/tests/Feature/ClientSms/SenderModelTest.php diff --git a/app/app/Models/ClientSmsAutoRule.php b/app/app/Models/ClientSmsAutoRule.php new file mode 100644 index 00000000..7ea7931c --- /dev/null +++ b/app/app/Models/ClientSmsAutoRule.php @@ -0,0 +1,36 @@ + 'integer', + 'enabled' => 'boolean', + 'updated_by' => 'integer', + ]; + } +} diff --git a/app/app/Models/ClientSmsSender.php b/app/app/Models/ClientSmsSender.php new file mode 100644 index 00000000..14f7b4f5 --- /dev/null +++ b/app/app/Models/ClientSmsSender.php @@ -0,0 +1,61 @@ + 'integer', + 'requested_by' => 'integer', + 'operators' => 'array', + 'monthly_fee_rub' => 'decimal:2', + 'consent_at' => 'datetime', + 'approved_at' => 'datetime', + 'paid_until' => 'date', + 'debt_since' => 'date', + ]; + } +} diff --git a/app/database/migrations/2026_07_28_100000_create_client_sms_senders.php b/app/database/migrations/2026_07_28_100000_create_client_sms_senders.php new file mode 100644 index 00000000..5d0a1cdb --- /dev/null +++ b/app/database/migrations/2026_07_28_100000_create_client_sms_senders.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name', 64); + $table->string('name_type', 16); + $table->string('status', 16)->default('pending'); + $table->json('operators')->nullable(); + $table->decimal('monthly_fee_rub', 10, 2)->default('0.00'); + $table->timestampTz('consent_at')->nullable(); + $table->string('note', 255)->nullable(); + $table->unsignedBigInteger('requested_by')->nullable(); + $table->timestampTz('approved_at')->nullable(); + $table->date('paid_until')->nullable(); + $table->date('debt_since')->nullable(); + $table->timestamps(); + + $table->unique('tenant_id'); + }); + + DB::statement('ALTER TABLE client_sms_senders ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE client_sms_senders FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_sms_senders'); + DB::statement("CREATE POLICY tenant_isolation ON client_sms_senders USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, UPDATE ON client_sms_senders TO crm_app_user'); + + DB::statement(<<<'SQL' +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT ON client_sms_senders TO crm_supplier_worker; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT, UPDATE ON client_sms_senders TO crm_admin_user; + END IF; +END $$; +SQL); + } + + public function down(): void + { + Schema::dropIfExists('client_sms_senders'); + } +}; diff --git a/app/database/migrations/2026_07_28_100100_create_client_sms_auto_rule.php b/app/database/migrations/2026_07_28_100100_create_client_sms_auto_rule.php new file mode 100644 index 00000000..c88d608b --- /dev/null +++ b/app/database/migrations/2026_07_28_100100_create_client_sms_auto_rule.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->boolean('enabled')->default(false); + $table->string('body', 1000); + $table->string('sender_name', 64); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->timestamps(); + + $table->unique('tenant_id'); + }); + + DB::statement('ALTER TABLE client_sms_auto_rule ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE client_sms_auto_rule FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_sms_auto_rule'); + DB::statement("CREATE POLICY tenant_isolation ON client_sms_auto_rule USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, UPDATE ON client_sms_auto_rule TO crm_app_user'); + + DB::statement(<<<'SQL' +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT ON client_sms_auto_rule TO crm_supplier_worker; + END IF; +END $$; +SQL); + } + + public function down(): void + { + Schema::dropIfExists('client_sms_auto_rule'); + } +}; diff --git a/app/database/migrations/2026_07_28_100200_grant_admin_wallet_write.php b/app/database/migrations/2026_07_28_100200_grant_admin_wallet_write.php new file mode 100644 index 00000000..2bb1040a --- /dev/null +++ b/app/database/migrations/2026_07_28_100200_grant_admin_wallet_write.php @@ -0,0 +1,48 @@ +count())->toBe(1); }); + +it('creates client_sms_senders with expected columns', function () { + expect(Schema::hasTable('client_sms_senders'))->toBeTrue(); + expect(Schema::hasColumns('client_sms_senders', [ + 'tenant_id', 'name', 'name_type', 'status', 'operators', + 'monthly_fee_rub', 'consent_at', 'note', 'requested_by', + 'approved_at', 'paid_until', 'debt_since', + ]))->toBeTrue(); +}); + +it('creates client_sms_auto_rule with expected columns', function () { + expect(Schema::hasTable('client_sms_auto_rule'))->toBeTrue(); + expect(Schema::hasColumns('client_sms_auto_rule', [ + 'tenant_id', 'enabled', 'body', 'sender_name', 'updated_by', + ]))->toBeTrue(); +}); diff --git a/app/tests/Feature/ClientSms/SenderModelTest.php b/app/tests/Feature/ClientSms/SenderModelTest.php new file mode 100644 index 00000000..bf0c40f8 --- /dev/null +++ b/app/tests/Feature/ClientSms/SenderModelTest.php @@ -0,0 +1,94 @@ +create(); + + $sender = ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'LIDERRA', + 'name_type' => ClientSmsSender::TYPE_COMPANY, + 'status' => ClientSmsSender::STATUS_PENDING, + 'operators' => ['mts'], + 'monthly_fee_rub' => '2500.00', + 'consent_at' => now(), + 'note' => null, + 'requested_by' => null, + 'approved_at' => null, + 'paid_until' => now()->toDateString(), + 'debt_since' => null, + ]); + + $sender->refresh(); + + expect($sender->operators)->toBeArray() + ->and($sender->operators)->toBe(['mts']) + ->and((string) $sender->monthly_fee_rub)->toBe('2500.00') + ->and($sender->paid_until)->toBeInstanceOf(Carbon\Carbon::class) + ->and($sender->status)->toBe(ClientSmsSender::STATUS_PENDING); +}); + +it('creates a client sms auto rule and reads back the boolean cast', function () { + $tenant = Tenant::factory()->create(); + + $rule = ClientSmsAutoRule::create([ + 'tenant_id' => $tenant->id, + 'enabled' => true, + 'body' => 'текст', + 'sender_name' => 'LIDERRA', + 'updated_by' => null, + ]); + + $rule->refresh(); + + expect($rule->enabled)->toBeTrue() + ->and($rule->enabled)->toBeBool(); +}); + +it('enforces a unique sender per tenant', function () { + $tenant = Tenant::factory()->create(); + + ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'LIDERRA', + 'name_type' => ClientSmsSender::TYPE_COMPANY, + 'status' => ClientSmsSender::STATUS_PENDING, + 'operators' => ['mts'], + 'monthly_fee_rub' => '0.00', + 'consent_at' => null, + 'note' => null, + 'requested_by' => null, + 'approved_at' => null, + 'paid_until' => null, + 'debt_since' => null, + ]); + + // Nested transaction (Postgres savepoint) so the aborted INSERT doesn't + // poison the outer RefreshDatabase transaction for the rest of the test. + expect(fn () => DB::transaction(fn () => ClientSmsSender::create([ + 'tenant_id' => $tenant->id, + 'name' => 'OTHER', + 'name_type' => ClientSmsSender::TYPE_WEBSITE, + 'status' => ClientSmsSender::STATUS_PENDING, + 'operators' => ['beeline'], + 'monthly_fee_rub' => '0.00', + 'consent_at' => null, + 'note' => null, + 'requested_by' => null, + 'approved_at' => null, + 'paid_until' => null, + 'debt_since' => null, + ])))->toThrow(QueryException::class); + + expect(ClientSmsSender::where('tenant_id', $tenant->id)->count())->toBe(1); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index d5c09ac9..10c945bc 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -8,6 +8,79 @@ > параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), > поэтому при сведении они перенумерованы. Содержание не менялось. +## v8.98 (2026-07-28) — Задача 13b: GRANT INSERT/UPDATE на рекламный кошелёк для crm_admin_user (админ-согласование платного имени отправителя) + +GRANT-only миграция `app/database/migrations/2026_07_28_100200_grant_admin_wallet_write.php` +— **`schema.sql` НЕ тронут** (только привилегии ролей, DDL таблиц без изменений). + +Причина: админ-согласование «своего имени отправителя» (Task 13b, экран +SaaS-admin → Клиентские СМС) при одобрении **списывает** месячную плату с +рекламного кошелька КЛИЕНТА и **снимает** order-time бронь; при отклонении — +снимает бронь. Эти операции выполняются под ролью `crm_admin_user` (middleware +`admin-db`) кросс-тенантно через `srv_bypass`. Но `srv_bypass` снимает лишь +RLS — табличных привилегий на запись он не даёт. Read-грант на кошельковые +таблицы уже был выдан `2026_07_25_100300_grant_admin_read_advertising` +(`SELECT`); здесь добавляется право записи, необходимое для +`AdWalletService::charge()`/`release()` из админ-зоны: + +- `GRANT INSERT, UPDATE ON ad_wallets TO crm_admin_user` — обновление + `balance_rub`/`frozen_rub`. +- `GRANT INSERT, UPDATE ON ad_wallet_holds TO crm_admin_user` — снятие брони + (`status` → `released`). +- `GRANT INSERT ON ad_wallet_transactions TO crm_admin_user` — проводка + списания (append-only журнал). + +Всё guarded (`pg_roles IF EXISTS crm_admin_user`) — на dev/test (postgres +superuser, роли нет) миграция инертна. `down()` — guarded REVOKE. + +TDD-first (RED→GREEN): `tests/Feature/ClientSms/AdminSenderTest.php` (5 тестов, +25 assertions) — GET списка pending, approve в реальном режиме (баланс +5000→2500, frozen 2500→0), approve в sandbox (кошелёк не тронут), reject +(frozen→0, баланс не тронут), approve на не-pending → 422. Полный набор +`tests/Feature/ClientSms/` — 60/60 зелёных, 232 assertions. + +## v8.97 (2026-07-28) — СМС-рассылки по клиентской базе (Задача 11) — 2 новые RLS-таблицы client_sms_senders / client_sms_auto_rule + +TDD-first (RED→GREEN): `tests/Feature/ClientSms/MigrationTest.php` дополнен +двумя новыми тестами (`Schema::hasTable`/`hasColumns`), подтверждён RED +(10 тестов, 2 падают — таблиц нет), затем 2 миграции +`app/database/migrations/2026_07_28_100000..100100_create_client_sms_senders.php` +и `..._auto_rule.php` довели до GREEN (10/10, 22 assertions). Полный набор +`tests/Feature/ClientSms/` — 43/43 зелёных, 161 assertions (регрессия не +затронута). + +Продолжение Этапа 2 (`db/CHANGELOG_schema.md` v8.96 — Этап 1) — фичи +«своё имя отправителя» и «авто-рассылка при создании сделки»: + +- `client_sms_senders` — заявка/статус собственного имени отправителя на + тенанта: `name`/`name_type` (`website`|`company`), `status` (умолчание + `'pending'`: `pending`|`active`|`suspended`|`rejected`|`cancelled`), + `operators` (json, список операторов вида `["mts"]`, заполняется в + приложении), `monthly_fee_rub`, `consent_at`, `note`, `requested_by`, + `approved_at`, `paid_until`, `debt_since`. UNIQUE `(tenant_id)` — одно + своё имя на клиента. RLS `tenant_isolation` (как у `client_sms_campaigns`). + GRANT `SELECT, INSERT, UPDATE` для `crm_app_user` (безусловно, как у + campaigns); guarded (`pg_roles IF EXISTS`) — `SELECT` для + `crm_supplier_worker` (помесячный cross-tenant джоб-энумератор) и + `SELECT, UPDATE` для `crm_admin_user` (админ-подтверждение/отклонение/ + отключение имени). +- `client_sms_auto_rule` — правило авто-рассылки СМС при создании сделки: + `enabled` (умолчание `false`), `body`, `sender_name`, `updated_by`. + UNIQUE `(tenant_id)`. RLS `tenant_isolation`. GRANT `SELECT, INSERT, + UPDATE` для `crm_app_user`; guarded — `SELECT` для `crm_supplier_worker` + (джоб авто-рассылки при создании сделки читает правило тенанта). + +Прогнано на `liderra_testing` — DONE. Структурно: +2 таблицы (обе RLS), ++1 RLS-политика на каждую (2 итого), 0 новых функций/триггеров. + +🔴 **Деплой-заметка (обязательно перед боевым накатом):** на боевом +кластере после наката этих двух таблиц **ПЕРЕзапустить** +`db/03_service_bypass_policies.sql` — иначе cross-tenant доступ +`crm_supplier_worker`/`crm_admin_user` к `client_sms_senders`/ +`client_sms_auto_rule` не появится (тихий ноль в помесячном джобе и +админ-подтверждении), по аналогии с 25.07.2026 (`project-reklama-modul- +vykat-2026-07-25`, «`srv_bypass`»). + ## v8.96 (2026-07-27) — СМС-рассылки по клиентской базе (Задача 1) — 8 новых таблиц client_sms_* TDD-first (RED→GREEN): `tests/Feature/ClientSms/MigrationTest.php` написан