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);