source->name === self::SOURCE; } /** * @param array{title:string, due_at:string, subtitle?:?string, fire_at?:?string, after_due?:?string, done_label?:?string, deep_link?:?string} $fields */ public function create(User $user, array $fields): Event { return $this->upsert($user, 'manual:'.Str::ulid(), self::item($fields)); } /** * Частичная правка своего события: не переданные поля берутся из него. Новый `due_at` — * это перенос (§5): состояние пересчитывается даже из терминального, а `fire_at`, * если не задан явно, считается заново от нового срока. * * @param array $fields */ public function update(Event $event, array $fields): Event { if (! self::owns($event)) { throw new LogicException("Событие источника «{$event->source->name}» правит только его источник"); } $current = EventPresenter::forClient($event); $merged = [ 'title' => $current['payload']['title'], 'subtitle' => $current['payload']['subtitle'] ?? null, 'done_label' => $current['payload']['done_label'] ?? null, 'deep_link' => $current['payload']['deep_link'] ?? null, 'due_at' => $current['due_at'], 'fire_at' => isset($fields['due_at']) ? null : $current['fire_at'], 'after_due' => $current['after_due'], ]; foreach ($fields as $key => $value) { $merged[$key] = $value; } return $this->upsert($event->user, $event->source_ref, self::item($merged)); } public function withdraw(Event $event): void { if (! self::owns($event)) { throw new LogicException("Событие источника «{$event->source->name}» снимает только его источник"); } $this->upserter->withdraw($event); } /** * Плоские поля → upsert-элемент с дефолтами кнопки инбокса. Проверяется теми же * правилами, что и события клиентов. * * @param array $fields * @return array */ private static function item(array $fields): array { $dueAt = (string) ($fields['due_at'] ?? ''); $item = [ 'due_at' => $dueAt, 'fire_at' => $fields['fire_at'] ?? self::defaultFireAt($dueAt), 'after_due' => $fields['after_due'] ?? 'keep', 'payload' => array_filter([ 'title' => $fields['title'] ?? null, 'subtitle' => $fields['subtitle'] ?? null, 'done_label' => $fields['done_label'] ?? 'Сделано', 'deep_link' => $fields['deep_link'] ?? null, ], fn ($v) => $v !== null && $v !== ''), ]; EventRules::validate($item); return $item; } /** @param array $item */ private function upsert(User $user, string $sourceRef, array $item): Event { [$result] = $this->upserter->upsert($this->source(), [['user' => $user->login, 'source_ref' => $sourceRef, 'topic' => ''] + $item]); return Event::with('source')->findOrFail($result['id']); } private function source(): Source { return Source::firstOrCreate(['name' => self::SOURCE], ['token_hash' => hash('sha256', Str::random(64))]); } /** День срока минус DEFAULT_LEAD_DAYS; из due_at берётся только дата. Кривой due_at отдаём правилам как есть. */ private static function defaultFireAt(string $dueAt): ?string { $day = CarbonImmutable::createFromFormat('Y-m-d', substr($dueAt, 0, 10)); return $day === false ? null : $day->subDays(self::DEFAULT_LEAD_DAYS)->format('Y-m-d'); } }