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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user