39b6127bce
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.
Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
окно ±1 день, counts для UI badges);
POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
POST {id}/complete (idempotent);
DELETE {id}.
RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
?? created_by (если active+!deleted). Каналы email+inapp по prefs.
payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
{--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
ставит is_sent=true даже если recipient deactivated (защита от retry-spam).
Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
store + FK guard / update text+remind_at сбрасывает is_sent / complete
idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
skip / уже sent / assignee вместо created_by / deactivated user (is_sent
всё равно) / только inapp при email=false / --dry-run / --limit / RLS.
PHPStan baseline регенерирован. IDE-helper для всех моделей.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.8 KiB
PHP
103 lines
3.8 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();
|
|
|
|
// Берём список pending-reminders. Без RLS — admin-flow на serverside.
|
|
// Для каждой устанавливаем app.current_tenant_id внутри транзакции.
|
|
$pending = Reminder::query()
|
|
->where('is_sent', false)
|
|
->whereNull('completed_at')
|
|
->where('remind_at', '<=', $now)
|
|
->orderBy('remind_at')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
if ($pending->isEmpty()) {
|
|
$this->info('Нет due-reminders.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$sent = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($pending as $reminder) {
|
|
if ($dryRun) {
|
|
$this->line(sprintf(
|
|
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
|
$reminder->id,
|
|
$reminder->tenant_id,
|
|
$reminder->deal_id,
|
|
$reminder->remind_at?->toIso8601String() ?? '-',
|
|
));
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
DB::transaction(function () use ($reminder, $service): void {
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $reminder->tenant_id);
|
|
$service->notifyReminder($reminder);
|
|
$reminder->update([
|
|
'is_sent' => true,
|
|
'sent_at' => Carbon::now(),
|
|
]);
|
|
});
|
|
$sent++;
|
|
$this->info(" dispatched <fg=green>id={$reminder->id}</>");
|
|
} catch (\Throwable $e) {
|
|
$failed++;
|
|
$this->error(" failed <fg=red>id={$reminder->id}</>: {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
$this->newLine();
|
|
$this->info("Done: {$sent} sent, {$failed} failed (limit={$limit}, dry-run=".($dryRun ? '1' : '0').').');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|