feat: tick planner, delivery job, scheduler and purge

Also: test uses disabled channels instead of deleting them (FK cascade); ChannelRegistry no longer final so tests can extend it (controller ruling).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 07:38:16 -03:00
parent 1b0a559e3c
commit affac4ce1a
13 changed files with 611 additions and 6 deletions

108
core/app/Delivery/Tick.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace App\Delivery;
use App\Channels\ChannelRegistry;
use App\Enums\DeliveryResult;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use App\Scheduling\StateResolver;
use App\Scheduling\Window;
use App\Scheduling\WindowResolver;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Один тик планировщика (раз в минуту): переходы состояний по датам и решение,
* доставлять ли напоминание в открытое окно и куда.
*/
final class Tick
{
public function __construct(
private readonly DueResolver $due,
private readonly StateResolver $states,
private readonly WindowResolver $windows,
private readonly ChannelRegistry $registry,
private readonly Cache $cache,
private readonly int $presenceTtl,
) {}
public function run(): void
{
Event::query()
->whereIn('state', EventState::nonTerminalValues())
->with('user')
->chunkById(200, fn (Collection $events) => $events->each(fn (Event $e) => $this->process($e)));
}
private function process(Event $event): void
{
$user = $event->user;
$now = $user->now();
$state = $this->states->resolve($event, $user, $now);
if ($state !== $event->state) {
$event->state = $state;
$event->save();
}
if (! $state->isActive() || $now->lt($event->quiet_until)) {
return;
}
$window = $this->windows->current($state, $user, $this->due->resolve($event, $user), $now);
if ($window === null) {
return;
}
if (Delivery::where('event_id', $event->id)->where('window_start', $window->start->utc())->exists()) {
return;
}
$channels = $user->channels()->where('enabled', true)->get();
if ($channels->isEmpty()) {
return;
}
$present = $channels->filter(fn (Channel $c) => $this->presence($c, $user) === Presence::Present);
if ($present->isNotEmpty()) {
$this->dispatch($event, $present, $window);
return;
}
if ($window->fallbackAllowed && $now->gte($window->fallbackAt)) {
$this->dispatch($event, $channels, $window);
}
}
private function presence(Channel $channel, User $user): Presence
{
return $this->cache->remember(
"presence:{$channel->id}",
$this->presenceTtl,
fn () => $this->registry->for($channel->type)->presence($user, $channel->config),
);
}
/** Строка pending — в транзакции ДО постановки job-а: следующий тик её уже видит. */
private function dispatch(Event $event, Collection $channels, Window $window): void
{
DB::transaction(function () use ($event, $channels, $window) {
foreach ($channels as $channel) {
$delivery = Delivery::create([
'event_id' => $event->id,
'channel_id' => $channel->id,
'window_start' => $window->start->utc(),
'action_token' => Str::random(48),
'result' => DeliveryResult::Pending,
]);
DeliverJob::dispatch($delivery->id)->afterCommit();
}
});
}
}