fb4e711b4a
Found by docs/audit/2026-05-23-rls-gap-audit.md. Each touched an RLS-protected table on the default connection in cron/queue context (no tenant GUC) — crash or silent misbehaviour on prod (crm_app_user, not BYPASSRLS), hidden on dev (superuser). - RemindersDispatchDue (Pattern B): gather pending via pgsql_supplier, then per-reminder DB::transaction + SET LOCAL app.current_tenant_id (isolation kept). - ReportsCleanupExpired (Pattern A): SaaS-admin cron → report_jobs + pd_processing_log via pgsql_supplier (BYPASSRLS). - GenerateReportJob (Pattern B): +readonly int $tenantId ctor param, wrap handle() in DB::transaction + SET LOCAL; both ReportJobController dispatch sites updated. - ProcessWebhookJob::failed (Pattern A): failed_webhook_jobs insert via pgsql_supplier → webhook failures now logged, incidents:watch-failures can see them. Tests +SharesSupplierPdo trait. 118 passed / 0 failed. My 5 src files pass larastan isolated (0 errors).
111 lines
4.3 KiB
PHP
111 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Reminder;
|
|
use App\Services\NotificationService;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Cron-команда диспатча due-reminders.
|
|
*
|
|
* Идёт по `reminders` где `is_sent=false AND completed_at IS NULL AND
|
|
* remind_at <= NOW()`. Для каждой строки:
|
|
* 1) NotificationService::notifyReminder (email + inapp по prefs);
|
|
* 2) UPDATE is_sent=true, sent_at=NOW().
|
|
*
|
|
* RLS: SET LOCAL app.current_tenant_id = reminder.tenant_id внутри
|
|
* транзакции каждой обработки (по одному reminder в транзакции — иначе
|
|
* нельзя переключить tenant между строками с разных tenant'ов).
|
|
*
|
|
* Запускается каждую минуту через Windows Task Scheduler / cron.
|
|
* Идемпотентна: повторный вызов на отправленных ($is_sent=true) skipаются.
|
|
*
|
|
* --dry-run печатает плановых получателей без реальных INSERT'ов.
|
|
*
|
|
* Источник: db/schema.sql §17.5; ТЗ §6.6 / §18.5.
|
|
*/
|
|
class RemindersDispatchDue extends Command
|
|
{
|
|
/** @var string */
|
|
protected $signature = 'reminders:dispatch-due
|
|
{--dry-run : Не отправлять, только напечатать список плановых получателей}
|
|
{--limit=500 : Максимум reminders за один запуск}';
|
|
|
|
/** @var string */
|
|
protected $description = 'Диспатч due-reminders: email/inapp уведомления + is_sent=true (ТЗ §18.5)';
|
|
|
|
public function handle(NotificationService $service): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$limit = max(1, (int) $this->option('limit'));
|
|
$now = Carbon::now();
|
|
|
|
// Cross-tenant gather via BYPASSRLS connection — on prod crm_app_user cannot
|
|
// call current_setting('app.current_tenant_id') without a GUC set first.
|
|
// pgsql_supplier (crm_supplier_worker, BYPASSRLS) is the canonical pattern
|
|
// for SaaS-admin cron queries (precedent: IncidentsWatchFailures, Reset*).
|
|
$rows = DB::connection('pgsql_supplier')
|
|
->table('reminders')
|
|
->select(['id', 'tenant_id', 'deal_id', 'remind_at'])
|
|
->where('is_sent', false)
|
|
->whereNull('completed_at')
|
|
->where('remind_at', '<=', $now)
|
|
->orderBy('remind_at')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
if ($rows->isEmpty()) {
|
|
$this->info('Нет due-reminders.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$sent = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($rows as $row) {
|
|
if ($dryRun) {
|
|
$this->line(sprintf(
|
|
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
|
$row->id,
|
|
$row->tenant_id,
|
|
$row->deal_id,
|
|
$row->remind_at ?? '-',
|
|
));
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
DB::transaction(function () use ($row, $service): void {
|
|
// SET LOCAL scopes GUC to this transaction — PgBouncer-safe.
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $row->tenant_id);
|
|
// Fetch the full Eloquent model with tenant context active so
|
|
// relations (user, etc.) work correctly inside NotificationService.
|
|
$reminder = Reminder::query()->findOrFail((int) $row->id);
|
|
$service->notifyReminder($reminder);
|
|
$reminder->update([
|
|
'is_sent' => true,
|
|
'sent_at' => Carbon::now(),
|
|
]);
|
|
});
|
|
$sent++;
|
|
$this->info(" dispatched <fg=green>id={$row->id}</>");
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("Done: {$sent} sent, {$failed} failed (limit={$limit}, dry-run=".($dryRun ? '1' : '0').').');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|