From 01287d080455a87098d5d305c3e1077664cebb47 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: Thu, 16 Jul 2026 13:45:33 +0300 Subject: [PATCH] =?UTF-8?q?fix(notifications):=20=D0=B4=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B6=D0=B5=D1=81=D1=82=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=BD=D0=B5=20=D1=83?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=20=D0=BD=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D1=82=D1=83=20=E2=80=94=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=82=D0=B5=D0=BD=D0=B0=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BB=D1=81=D1=8F=20=D0=BF=D0=BE=D0=B4=20RLS?= =?UTF-8?q?-=D1=80=D0=BE=D0=BB=D1=8C=D1=8E=20(0=20=D1=84=D0=B8=D1=80=D0=BC?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SendNewLeadsDigestJob брал перечень тенантов через дефолтное соединение (crm_app_user, RLS). У очереди нет app.current_tenant_id → policy tenants_self_isolation отдавала 0 строк, и рассылка молча превращалась в no-op: ни одного письма о новых сделках с 19.06.2026 (проверено на бою — 0 из 58 сделок за сутки помечены отправленными). Лечение зеркалит уже принятый фикс BalancePreflightSweepJob: перечень id берём через pgsql_supplier (BYPASSRLS), затем per-tenant SET LOCAL внутри digestForTenant восстанавливает контекст под RLS-ролью. Тест: +регрессия «рассылает нескольким тенантам за прогон при системном контексте 0»; +SharesSupplierPdo (иначе pgsql_supplier не видит незакоммиченного тенанта). Проверено сломом выборки → 4 теста краснеют. На бою сухим прогоном (Mail::fake): старый код 0 фирм → 0 писем, новый 9 фирм → 1 дайджест сформирован. Escape: владелец дал явное «коммить пуш и кати». Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/SendNewLeadsDigestJob.php | 37 +++++++++++++------ .../Notifications/NewLeadsDigestJobTest.php | 36 ++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/app/app/Jobs/SendNewLeadsDigestJob.php b/app/app/Jobs/SendNewLeadsDigestJob.php index 6cd54715..2f4e1362 100644 --- a/app/app/Jobs/SendNewLeadsDigestJob.php +++ b/app/app/Jobs/SendNewLeadsDigestJob.php @@ -9,7 +9,6 @@ use App\Models\Tenant; use App\Services\NotificationService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Cache; @@ -34,21 +33,37 @@ final class SendNewLeadsDigestJob implements ShouldQueue public function handle(NotificationService $notifier): void { - Tenant::query()->whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void { - foreach ($tenants as $tenant) { - /** @var Tenant $tenant */ - $this->digestForTenant($tenant, $notifier); - } - }); + // Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user + // (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без + // app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и + // дайджест молча превращался в no-op (ни одного письма о новых сделках с + // 19.06.2026). Берём id через pgsql_supplier (BYPASSRLS — системный контекст), + // как это уже делает BalancePreflightSweepJob. Дальше per-tenant SET LOCAL + // внутри digestForTenant восстанавливает контекст под RLS-ролью. + $tenantIds = DB::connection('pgsql_supplier')->table('tenants') + ->whereNull('deleted_at') + ->orderBy('id') + ->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $this->digestForTenant((int) $tenantId, $notifier); + } } - private function digestForTenant(Tenant $tenant, NotificationService $notifier): void + private function digestForTenant(int $tenantId, NotificationService $notifier): void { - DB::transaction(function () use ($tenant, $notifier): void { - DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); + DB::transaction(function () use ($tenantId, $notifier): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + // Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find + // вернёт null (id-isolation policy). После SET LOCAL своя запись видна. + $tenant = Tenant::find($tenantId); + if ($tenant === null) { + return; // удалён между pluck и обработкой — пропускаем. + } $deals = Deal::query() - ->where('tenant_id', $tenant->id) + ->where('tenant_id', $tenantId) ->where('received_at', '>', now()->subMinutes(30)) ->where('is_test', false) ->whereNull('deleted_at') diff --git a/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php b/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php index 10b645d4..1e7d365c 100644 --- a/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php +++ b/app/tests/Feature/Notifications/NewLeadsDigestJobTest.php @@ -14,8 +14,13 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +use Tests\Concerns\SharesSupplierPdo; +// Перечень тенантов джоба берёт через pgsql_supplier (BYPASSRLS — иначе под RLS-ролью +// очереди список пуст, дайджест = no-op). SharesSupplierPdo делает pgsql_supplier общим +// PDO с pgsql, иначе незакоммиченный Tenant::factory() не виден и джоба шлёт 0 писем. uses(DatabaseTransactions::class); +uses(SharesSupplierPdo::class); beforeEach(function () { Mail::fake(); @@ -119,6 +124,37 @@ it('при падении отправки НЕ помечает сделки Mail::assertSent(NewLeadsDigestMail::class, 1); }); +it('рассылает дайджест НЕСКОЛЬКИМ тенантам за один прогон, не завися от текущего RLS-контекста', function () { + // Регрессия на «тихий no-op»: планировщик бежит БЕЗ контекста конкретной фирмы + // (системный tenant 0). Перечень фирм джоба обязана взять сама (через pgsql_supplier), + // а не через RLS-ограниченное дефолтное соединение. Раньше список выходил пустым и + // ни одно письмо о новых сделках не уходило. + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + digestUser($tenantA, 'sweep-a@example.test', true); + digestUser($tenantB, 'sweep-b@example.test', true); + + DB::statement('SET app.current_tenant_id = '.$tenantA->id); + Deal::factory()->for($tenantA)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); + DB::statement('SET app.current_tenant_id = '.$tenantB->id); + Deal::factory()->for($tenantB)->create(['received_at' => now()->subMinutes(5), 'is_test' => false]); + Deal::factory()->for($tenantB)->create(['received_at' => now()->subMinutes(6), 'is_test' => false]); + + // Планировщик реально запускает джобу без контекста своей фирмы. + DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); + + (new SendNewLeadsDigestJob)->handle(app(NotificationService::class)); + + Mail::assertSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('sweep-a@example.test') && $m->deals->count() === 1, + ); + Mail::assertSent( + NewLeadsDigestMail::class, + fn (NewLeadsDigestMail $m) => $m->hasTo('sweep-b@example.test') && $m->deals->count() === 2, + ); +}); + it('notifyNewLead больше не шлёт пер-лид письмо', function () { $tenant = Tenant::factory()->create(); digestUser($tenant, 'perlead@example.test', true);