fix(notifications): дайджест новых сделок не уходил на почту — список тенантов брался под RLS-ролью (0 фирм)

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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-16 13:45:33 +03:00
parent a29f10a25f
commit 01287d0804
2 changed files with 62 additions and 11 deletions
+26 -11
View File
@@ -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')
@@ -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);