From 1862d5ce9a252c52d09317c14999e729512b4b80 Mon Sep 17 00:00:00 2001 From: James Brooks Date: Tue, 13 Jan 2026 08:58:47 +0000 Subject: [PATCH] Add event sourcing with Verbs for audit trail and replay capability This implements a hybrid event sourcing architecture using Verbs by Thunk. Eloquent remains the source of truth for reads (Filament, API, status page), while Verbs captures all state changes for audit trails and replay capability. ## New Files ### State Classes (4) - `src/Verbs/States/ComponentState.php` - `src/Verbs/States/ComponentGroupState.php` - `src/Verbs/States/IncidentState.php` - `src/Verbs/States/ScheduleState.php` ### Event Classes (20) - Components: Created, Updated, Deleted, StatusChanged - ComponentGroups: Created, Updated, Deleted - Incidents: Created, Updated, Deleted, UpdateRecorded, ComponentAttached/Detached - Schedules: Created, Updated, Deleted, Completed, UpdateRecorded, ComponentAttached/Detached ### Migration - `database/migrations/2026_01_12_000000_initialize_verbs_events.php` Creates initial Verbs events for existing data ## Modified Files ### Action Classes All create/update/delete actions now fire Verbs events instead of direct Eloquent operations. The events handle persistence and dispatch existing webhook events via `Verbs::unlessReplaying()`. ### Filament Pages All Filament create/edit pages now use action classes to ensure state changes are captured by Verbs. The Components widget status toggle also fires ComponentStatusChanged events. ### Models Removed `$dispatchesEvents` from Component and Incident models since webhook events are now dispatched from Verbs event handlers. Co-Authored-By: Claude Opus 4.5 --- composer.json | 1 + ...6_01_12_000000_initialize_verbs_events.php | 154 ++++++++++++++++++ src/Actions/Component/CreateComponent.php | 15 +- src/Actions/Component/DeleteComponent.php | 5 +- src/Actions/Component/UpdateComponent.php | 34 +++- .../ComponentGroup/CreateComponentGroup.php | 33 ++-- .../ComponentGroup/DeleteComponentGroup.php | 12 +- .../ComponentGroup/UpdateComponentGroup.php | 23 ++- src/Actions/Incident/CreateIncident.php | 34 ++-- src/Actions/Incident/DeleteIncident.php | 5 +- src/Actions/Incident/UpdateIncident.php | 18 +- src/Actions/Schedule/CreateSchedule.php | 26 +-- src/Actions/Schedule/DeleteSchedule.php | 3 +- src/Actions/Schedule/UpdateSchedule.php | 41 ++++- src/Actions/Update/CreateUpdate.php | 24 ++- .../Pages/CreateComponentGroup.php | 10 ++ .../Pages/EditComponentGroup.php | 14 ++ .../Components/Pages/CreateComponent.php | 10 ++ .../Components/Pages/EditComponent.php | 14 ++ .../Incidents/Pages/CreateIncident.php | 10 ++ .../Incidents/Pages/EditIncident.php | 14 ++ .../Schedules/Pages/CreateSchedule.php | 27 +++ .../Schedules/Pages/EditSchedule.php | 13 +- .../Resources/Schedules/ScheduleResource.php | 6 +- src/Filament/Widgets/Components.php | 11 +- src/Models/Component.php | 9 - src/Models/Incident.php | 11 +- .../ComponentGroups/ComponentGroupCreated.php | 52 ++++++ .../ComponentGroups/ComponentGroupDeleted.php | 35 ++++ .../ComponentGroups/ComponentGroupUpdated.php | 65 ++++++++ .../Events/Components/ComponentCreated.php | 79 +++++++++ .../Events/Components/ComponentDeleted.php | 52 ++++++ .../Components/ComponentStatusChanged.php | 54 ++++++ .../Events/Components/ComponentUpdated.php | 126 ++++++++++++++ .../Incidents/ComponentAttachedToIncident.php | 52 ++++++ .../ComponentDetachedFromIncident.php | 47 ++++++ .../Events/Incidents/IncidentCreated.php | 105 ++++++++++++ .../Events/Incidents/IncidentDeleted.php | 53 ++++++ .../Incidents/IncidentUpdateRecorded.php | 65 ++++++++ .../Events/Incidents/IncidentUpdated.php | 97 +++++++++++ .../Schedules/ComponentAttachedToSchedule.php | 52 ++++++ .../ComponentDetachedFromSchedule.php | 47 ++++++ .../Events/Schedules/ScheduleCompleted.php | 43 +++++ .../Events/Schedules/ScheduleCreated.php | 71 ++++++++ .../Events/Schedules/ScheduleDeleted.php | 49 ++++++ .../Schedules/ScheduleUpdateRecorded.php | 55 +++++++ .../Events/Schedules/ScheduleUpdated.php | 63 +++++++ src/Verbs/States/ComponentGroupState.php | 23 +++ src/Verbs/States/ComponentState.php | 36 ++++ src/Verbs/States/IncidentState.php | 44 +++++ src/Verbs/States/ScheduleState.php | 26 +++ tests/TestCase.php | 4 + 52 files changed, 1864 insertions(+), 108 deletions(-) create mode 100644 database/migrations/2026_01_12_000000_initialize_verbs_events.php create mode 100644 src/Verbs/Events/ComponentGroups/ComponentGroupCreated.php create mode 100644 src/Verbs/Events/ComponentGroups/ComponentGroupDeleted.php create mode 100644 src/Verbs/Events/ComponentGroups/ComponentGroupUpdated.php create mode 100644 src/Verbs/Events/Components/ComponentCreated.php create mode 100644 src/Verbs/Events/Components/ComponentDeleted.php create mode 100644 src/Verbs/Events/Components/ComponentStatusChanged.php create mode 100644 src/Verbs/Events/Components/ComponentUpdated.php create mode 100644 src/Verbs/Events/Incidents/ComponentAttachedToIncident.php create mode 100644 src/Verbs/Events/Incidents/ComponentDetachedFromIncident.php create mode 100644 src/Verbs/Events/Incidents/IncidentCreated.php create mode 100644 src/Verbs/Events/Incidents/IncidentDeleted.php create mode 100644 src/Verbs/Events/Incidents/IncidentUpdateRecorded.php create mode 100644 src/Verbs/Events/Incidents/IncidentUpdated.php create mode 100644 src/Verbs/Events/Schedules/ComponentAttachedToSchedule.php create mode 100644 src/Verbs/Events/Schedules/ComponentDetachedFromSchedule.php create mode 100644 src/Verbs/Events/Schedules/ScheduleCompleted.php create mode 100644 src/Verbs/Events/Schedules/ScheduleCreated.php create mode 100644 src/Verbs/Events/Schedules/ScheduleDeleted.php create mode 100644 src/Verbs/Events/Schedules/ScheduleUpdateRecorded.php create mode 100644 src/Verbs/Events/Schedules/ScheduleUpdated.php create mode 100644 src/Verbs/States/ComponentGroupState.php create mode 100644 src/Verbs/States/ComponentState.php create mode 100644 src/Verbs/States/IncidentState.php create mode 100644 src/Verbs/States/ScheduleState.php diff --git a/composer.json b/composer.json index f409f892..7d1f2c3c 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "filament/filament": "^4.0", "filament/spatie-laravel-settings-plugin": "^4.0", "guzzlehttp/guzzle": "^7.8", + "hirethunk/verbs": "^0.7.0", "illuminate/cache": "^11.35.0", "illuminate/console": "^11.35.0", "illuminate/database": "^11.35.0", diff --git a/database/migrations/2026_01_12_000000_initialize_verbs_events.php b/database/migrations/2026_01_12_000000_initialize_verbs_events.php new file mode 100644 index 00000000..dbea4cc9 --- /dev/null +++ b/database/migrations/2026_01_12_000000_initialize_verbs_events.php @@ -0,0 +1,154 @@ +exists()) { + return; + } + + $this->migrateComponentGroups(); + $this->migrateComponents(); + $this->migrateIncidents(); + $this->migrateSchedules(); + + Verbs::commit(); + } + + public function down(): void + { + // Truncate Verbs tables to remove migrated events + DB::table('verb_state_events')->truncate(); + DB::table('verb_snapshots')->truncate(); + DB::table('verb_events')->truncate(); + } + + private function migrateComponentGroups(): void + { + ComponentGroup::each(function (ComponentGroup $group) { + ComponentGroupCreated::fire( + component_group_id: $group->id, + name: $group->name, + order: $group->order ?? 0, + collapsed: $group->collapsed ?? ComponentGroupVisibilityEnum::expanded, + visible: $group->visible ?? ResourceVisibilityEnum::guest, + ); + }); + } + + private function migrateComponents(): void + { + Component::withTrashed()->each(function (Component $component) { + ComponentCreated::fire( + component_id: $component->id, + name: $component->name, + status: $component->status ?? ComponentStatusEnum::operational, + description: $component->description, + link: $component->link, + order: $component->order ?? 0, + component_group_id: $component->component_group_id, + enabled: $component->enabled ?? true, + meta: $component->meta ?? [], + ); + + if ($component->deleted_at) { + ComponentDeleted::fire(component_id: $component->id); + } + }); + } + + private function migrateIncidents(): void + { + Incident::withTrashed()->with(['components', 'updates'])->each(function (Incident $incident) { + $components = $incident->components->map(fn ($c) => [ + 'id' => $c->id, + 'status' => $c->pivot->component_status ?? ComponentStatusEnum::operational, + ])->all(); + + IncidentCreated::fire( + incident_id: $incident->id, + name: $incident->name, + status: $incident->status ?? IncidentStatusEnum::investigating, + message: $incident->message ?? '', + visible: $incident->visible ?? ResourceVisibilityEnum::guest, + stickied: $incident->stickied ?? false, + notifications: boolval($incident->notifications) ?? false, + occurred_at: $incident->occurred_at?->toIso8601String(), + user_id: $incident->user_id, + external_provider: $incident->external_provider, + external_id: $incident->external_id, + components: $components, + guid: $incident->guid, + ); + + foreach ($incident->updates as $update) { + IncidentUpdateRecorded::fire( + incident_id: $incident->id, + status: $update->status ?? IncidentStatusEnum::investigating, + message: $update->message ?? '', + user_id: $update->user_id, + ); + } + + if ($incident->deleted_at) { + IncidentDeleted::fire(incident_id: $incident->id); + } + }); + } + + private function migrateSchedules(): void + { + Schedule::withTrashed()->with(['components', 'updates'])->each(function (Schedule $schedule) { + $components = $schedule->components->map(fn ($c) => [ + 'id' => $c->id, + 'status' => $c->pivot->component_status ?? ComponentStatusEnum::operational, + ])->all(); + + ScheduleCreated::fire( + schedule_id: $schedule->id, + name: $schedule->name, + message: $schedule->message, + scheduled_at: $schedule->scheduled_at?->toIso8601String(), + completed_at: $schedule->completed_at?->toIso8601String(), + components: $components, + ); + + foreach ($schedule->updates as $update) { + ScheduleUpdateRecorded::fire( + schedule_id: $schedule->id, + message: $update->message ?? '', + status: $update->status, + user_id: $update->user_id, + ); + } + + if ($schedule->deleted_at) { + ScheduleDeleted::fire(schedule_id: $schedule->id); + } + }); + } +}; diff --git a/src/Actions/Component/CreateComponent.php b/src/Actions/Component/CreateComponent.php index 8ad0fc38..1a206195 100644 --- a/src/Actions/Component/CreateComponent.php +++ b/src/Actions/Component/CreateComponent.php @@ -3,15 +3,26 @@ namespace Cachet\Actions\Component; use Cachet\Data\Requests\Component\CreateComponentRequestData; +use Cachet\Enums\ComponentStatusEnum; use Cachet\Models\Component; +use Cachet\Verbs\Events\Components\ComponentCreated; class CreateComponent { /** * Handle the action. */ - public function handle(CreateComponentRequestData $component): Component + public function handle(CreateComponentRequestData $data): Component { - return Component::create($component->toArray()); + return ComponentCreated::commit( + name: $data->name, + status: $data->status ?? ComponentStatusEnum::operational, + description: $data->description, + link: $data->link, + order: $data->order ?? 0, + component_group_id: $data->componentGroupId, + enabled: $data->enabled, + meta: [], + ); } } diff --git a/src/Actions/Component/DeleteComponent.php b/src/Actions/Component/DeleteComponent.php index 18c70639..b9438731 100644 --- a/src/Actions/Component/DeleteComponent.php +++ b/src/Actions/Component/DeleteComponent.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Component; use Cachet\Models\Component; +use Cachet\Verbs\Events\Components\ComponentDeleted; class DeleteComponent { @@ -11,8 +12,6 @@ class DeleteComponent */ public function handle(Component $component): void { - $component->subscribers()->detach(); - - $component->delete(); + ComponentDeleted::commit(component_id: $component->id); } } diff --git a/src/Actions/Component/UpdateComponent.php b/src/Actions/Component/UpdateComponent.php index b82b1e41..ac81c671 100644 --- a/src/Actions/Component/UpdateComponent.php +++ b/src/Actions/Component/UpdateComponent.php @@ -3,8 +3,9 @@ namespace Cachet\Actions\Component; use Cachet\Data\Requests\Component\UpdateComponentRequestData; -use Cachet\Events\Components\ComponentStatusWasChanged; use Cachet\Models\Component; +use Cachet\Verbs\Events\Components\ComponentStatusChanged; +use Cachet\Verbs\Events\Components\ComponentUpdated; class UpdateComponent { @@ -14,17 +15,32 @@ class UpdateComponent public function handle(Component $component, UpdateComponentRequestData $data): Component { $oldStatus = $component->status; + $hasStatusChange = $data->status !== null && $data->status !== $oldStatus; - $component->update($data->toArray()); - - if ($component->wasChanged('status')) { - ComponentStatusWasChanged::dispatch( - $component, - $oldStatus, - $component->status + // Fire status change event if status is changing + if ($hasStatusChange) { + ComponentStatusChanged::commit( + component_id: $component->id, + old_status: $oldStatus, + new_status: $data->status, ); } - return $component->fresh(); + // Fire general update event for other changes + ComponentUpdated::commit( + component_id: $component->id, + name: $data->name, + status: $hasStatusChange ? null : $data->status, // Skip status if already handled + description: $data->description, + link: $data->link, + order: $data->order, + component_group_id: $data->componentGroupId, + enabled: $data->enabled, + ); + + // Refresh the original model with updated data + $component->refresh(); + + return $component; } } diff --git a/src/Actions/ComponentGroup/CreateComponentGroup.php b/src/Actions/ComponentGroup/CreateComponentGroup.php index 94e2e3f5..4c367a4b 100644 --- a/src/Actions/ComponentGroup/CreateComponentGroup.php +++ b/src/Actions/ComponentGroup/CreateComponentGroup.php @@ -3,29 +3,36 @@ namespace Cachet\Actions\ComponentGroup; use Cachet\Data\Requests\ComponentGroup\CreateComponentGroupRequestData; -use Cachet\Models\Component; +use Cachet\Enums\ComponentGroupVisibilityEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\ComponentGroup; +use Cachet\Verbs\Events\ComponentGroups\ComponentGroupCreated; +use Cachet\Verbs\Events\Components\ComponentUpdated; class CreateComponentGroup { - /** - * Handle the action. - */ /** * Handle the action. */ public function handle(CreateComponentGroupRequestData $data): ComponentGroup { - return tap(ComponentGroup::create( - $data->except('components')->toArray(), - ), function (ComponentGroup $componentGroup) use ($data) { - if (! $data->components) { - return; + $componentGroup = ComponentGroupCreated::commit( + name: $data->name, + order: $data->order ?? 0, + collapsed: ComponentGroupVisibilityEnum::expanded, + visible: $data->visible ?? ResourceVisibilityEnum::guest, + ); + + // Assign components to the group via update events + if ($data->components) { + foreach ($data->components as $componentId) { + ComponentUpdated::commit( + component_id: $componentId, + component_group_id: $componentGroup->id, + ); } + } - Component::query()->whereIn('id', $data->components)->update([ - 'component_group_id' => $componentGroup->id, - ]); - }); + return $componentGroup; } } diff --git a/src/Actions/ComponentGroup/DeleteComponentGroup.php b/src/Actions/ComponentGroup/DeleteComponentGroup.php index cd017e30..091ce234 100644 --- a/src/Actions/ComponentGroup/DeleteComponentGroup.php +++ b/src/Actions/ComponentGroup/DeleteComponentGroup.php @@ -3,6 +3,8 @@ namespace Cachet\Actions\ComponentGroup; use Cachet\Models\ComponentGroup; +use Cachet\Verbs\Events\ComponentGroups\ComponentGroupDeleted; +use Cachet\Verbs\Events\Components\ComponentUpdated; class DeleteComponentGroup { @@ -11,8 +13,14 @@ class DeleteComponentGroup */ public function handle(ComponentGroup $componentGroup): void { - $componentGroup->components()->update(['component_group_id' => null]); + // First, unassign all components from this group + foreach ($componentGroup->components as $component) { + ComponentUpdated::commit( + component_id: $component->id, + clear_component_group: true, + ); + } - $componentGroup->delete(); + ComponentGroupDeleted::commit(component_group_id: $componentGroup->id); } } diff --git a/src/Actions/ComponentGroup/UpdateComponentGroup.php b/src/Actions/ComponentGroup/UpdateComponentGroup.php index d61e1ac8..3c6128bc 100644 --- a/src/Actions/ComponentGroup/UpdateComponentGroup.php +++ b/src/Actions/ComponentGroup/UpdateComponentGroup.php @@ -3,8 +3,9 @@ namespace Cachet\Actions\ComponentGroup; use Cachet\Data\Requests\ComponentGroup\UpdateComponentGroupRequestData; -use Cachet\Models\Component; use Cachet\Models\ComponentGroup; +use Cachet\Verbs\Events\ComponentGroups\ComponentGroupUpdated; +use Cachet\Verbs\Events\Components\ComponentUpdated; class UpdateComponentGroup { @@ -13,14 +14,24 @@ class UpdateComponentGroup */ public function handle(ComponentGroup $componentGroup, UpdateComponentGroupRequestData $data): ComponentGroup { - $componentGroup->update($data->except('components')->toArray()); + $result = ComponentGroupUpdated::commit( + component_group_id: $componentGroup->id, + name: $data->name, + order: $data->order, + collapsed: $data->collapsed ?? null, + visible: $data->visible, + ); + // Assign components to the group via update events if ($data->components) { - Component::query()->whereIn('id', $data->components)->update([ - 'component_group_id' => $componentGroup->id, - ]); + foreach ($data->components as $componentId) { + ComponentUpdated::commit( + component_id: $componentId, + component_group_id: $componentGroup->id, + ); + } } - return $componentGroup->fresh(); + return $result; } } diff --git a/src/Actions/Incident/CreateIncident.php b/src/Actions/Incident/CreateIncident.php index 0901cb72..2ad5b244 100644 --- a/src/Actions/Incident/CreateIncident.php +++ b/src/Actions/Incident/CreateIncident.php @@ -4,11 +4,13 @@ use Cachet\Data\Requests\Incident\CreateIncidentRequestData; use Cachet\Data\Requests\Incident\IncidentComponentRequestData; +use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Models\Component; use Cachet\Models\Incident; use Cachet\Models\IncidentTemplate; +use Cachet\Verbs\Events\Incidents\IncidentCreated; use Illuminate\Support\Carbon; -use Illuminate\Support\Str; class CreateIncident { @@ -25,23 +27,25 @@ public function handle(CreateIncidentRequestData $data): Incident $data = $data->withMessage($this->parseTemplate($template, $data)); } - // @todo Dispatch notification that incident was created. - - return tap(Incident::create(array_merge( - ['guid' => Str::uuid()], - $data->except('components')->toArray() - )), function (Incident $incident) use ($data) { - if (! $data->components) { - return; - } - + $components = []; + if ($data->components) { $components = collect($data->components)->map(fn (IncidentComponentRequestData $component) => [ - 'component_id' => $component->id, - 'component_status' => $component->status, + 'id' => $component->id, + 'status' => $component->status, ])->all(); + } - $incident->components()->sync($components); - }); + return IncidentCreated::commit( + name: $data->name, + status: $data->status ?? IncidentStatusEnum::investigating, + message: $data->message ?? '', + visible: $data->visible ? ResourceVisibilityEnum::guest : ResourceVisibilityEnum::authenticated, + stickied: $data->stickied, + notifications: $data->notifications, + occurred_at: $data->occurredAt, + user_id: auth()->id(), + components: $components, + ); } /** diff --git a/src/Actions/Incident/DeleteIncident.php b/src/Actions/Incident/DeleteIncident.php index 4e1354ae..701f3ba1 100644 --- a/src/Actions/Incident/DeleteIncident.php +++ b/src/Actions/Incident/DeleteIncident.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Incident; use Cachet\Models\Incident; +use Cachet\Verbs\Events\Incidents\IncidentDeleted; class DeleteIncident { @@ -11,8 +12,6 @@ class DeleteIncident */ public function handle(Incident $incident): void { - $incident->updates()->delete(); - - $incident->delete(); + IncidentDeleted::commit(incident_id: $incident->id); } } diff --git a/src/Actions/Incident/UpdateIncident.php b/src/Actions/Incident/UpdateIncident.php index bc21dea6..8d214864 100644 --- a/src/Actions/Incident/UpdateIncident.php +++ b/src/Actions/Incident/UpdateIncident.php @@ -3,8 +3,8 @@ namespace Cachet\Actions\Incident; use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; -use Cachet\Events\Incidents\IncidentUpdated; use Cachet\Models\Incident; +use Cachet\Verbs\Events\Incidents\IncidentUpdated; class UpdateIncident { @@ -13,10 +13,20 @@ class UpdateIncident */ public function handle(Incident $incident, UpdateIncidentRequestData $data): Incident { - $incident->update($data->toArray()); + IncidentUpdated::commit( + incident_id: $incident->id, + name: $data->name, + status: $data->status, + message: $data->message, + visible: $data->visible, + stickied: $data->stickied, + notifications: $data->notifications, + occurred_at: $data->occurredAt, + ); - IncidentUpdated::dispatch($incident); + // Refresh the original model with updated data + $incident->refresh(); - return $incident->fresh(); + return $incident; } } diff --git a/src/Actions/Schedule/CreateSchedule.php b/src/Actions/Schedule/CreateSchedule.php index 42e901e6..d890722f 100644 --- a/src/Actions/Schedule/CreateSchedule.php +++ b/src/Actions/Schedule/CreateSchedule.php @@ -5,6 +5,7 @@ use Cachet\Data\Requests\Schedule\CreateScheduleRequestData; use Cachet\Data\Requests\Schedule\ScheduleComponentRequestData; use Cachet\Models\Schedule; +use Cachet\Verbs\Events\Schedules\ScheduleCreated; class CreateSchedule { @@ -13,21 +14,20 @@ class CreateSchedule */ public function handle(CreateScheduleRequestData $data): Schedule { - - /** @phpstan-ignore-next-line argument.type */ - return tap(Schedule::create($data->except('components')->toArray()), function (Schedule $schedule) use ($data) { - if (! $data->components) { - return; - } - + $components = []; + if ($data->components) { $components = collect($data->components)->map(fn (ScheduleComponentRequestData $component) => [ - 'component_id' => $component->id, - 'component_status' => $component->status, + 'id' => $component->id, + 'status' => $component->status, ])->all(); + } - $schedule->components()->sync($components); - - // @todo Dispatch notification that maintenance was scheduled. - }); + return ScheduleCreated::commit( + name: $data->name, + message: $data->message, + scheduled_at: $data->scheduledAt->toIso8601String(), + completed_at: $data->completedAt?->toIso8601String(), + components: $components, + ); } } diff --git a/src/Actions/Schedule/DeleteSchedule.php b/src/Actions/Schedule/DeleteSchedule.php index 20f3399f..01994d81 100644 --- a/src/Actions/Schedule/DeleteSchedule.php +++ b/src/Actions/Schedule/DeleteSchedule.php @@ -3,6 +3,7 @@ namespace Cachet\Actions\Schedule; use Cachet\Models\Schedule; +use Cachet\Verbs\Events\Schedules\ScheduleDeleted; class DeleteSchedule { @@ -11,6 +12,6 @@ class DeleteSchedule */ public function handle(Schedule $schedule): void { - $schedule->delete(); + ScheduleDeleted::commit(schedule_id: $schedule->id); } } diff --git a/src/Actions/Schedule/UpdateSchedule.php b/src/Actions/Schedule/UpdateSchedule.php index 1262fbbd..82a8339c 100644 --- a/src/Actions/Schedule/UpdateSchedule.php +++ b/src/Actions/Schedule/UpdateSchedule.php @@ -5,6 +5,9 @@ use Cachet\Data\Requests\Schedule\ScheduleComponentRequestData; use Cachet\Data\Requests\Schedule\UpdateScheduleRequestData; use Cachet\Models\Schedule; +use Cachet\Verbs\Events\Schedules\ComponentAttachedToSchedule; +use Cachet\Verbs\Events\Schedules\ComponentDetachedFromSchedule; +use Cachet\Verbs\Events\Schedules\ScheduleUpdated; class UpdateSchedule { @@ -13,19 +16,41 @@ class UpdateSchedule */ public function handle(Schedule $schedule, UpdateScheduleRequestData $data): Schedule { - $schedule->update($data->except('components')->toArray()); + $result = ScheduleUpdated::commit( + schedule_id: $schedule->id, + name: $data->name, + message: $data->message, + scheduled_at: $data->scheduledAt, + ); if ($data->components) { - $components = collect($data->components)->map(fn (ScheduleComponentRequestData $component) => [ - 'component_id' => $component->id, - 'component_status' => $component->status, - ])->all(); + $currentComponentIds = $schedule->components()->pluck('components.id')->all(); + $newComponentIds = collect($data->components)->pluck('id')->all(); - $schedule->components()->sync($components); + // Detach removed components + foreach (array_diff($currentComponentIds, $newComponentIds) as $componentId) { + ComponentDetachedFromSchedule::commit( + schedule_id: $schedule->id, + component_id: $componentId, + ); + } + + // Attach new components + foreach ($data->components as $component) { + /** @var ScheduleComponentRequestData $component */ + if (! in_array($component->id, $currentComponentIds)) { + ComponentAttachedToSchedule::commit( + schedule_id: $schedule->id, + component_id: $component->id, + component_status: $component->status, + ); + } + } } - // @todo Dispatch notification that maintenance was updated. + // Refresh the original model with updated data + $schedule->refresh(); - return $schedule->fresh(); + return $schedule; } } diff --git a/src/Actions/Update/CreateUpdate.php b/src/Actions/Update/CreateUpdate.php index 75f8ecd7..50622e14 100644 --- a/src/Actions/Update/CreateUpdate.php +++ b/src/Actions/Update/CreateUpdate.php @@ -7,6 +7,8 @@ use Cachet\Models\Incident; use Cachet\Models\Schedule; use Cachet\Models\Update; +use Cachet\Verbs\Events\Incidents\IncidentUpdateRecorded; +use Cachet\Verbs\Events\Schedules\ScheduleUpdateRecorded; class CreateUpdate { @@ -15,12 +17,22 @@ class CreateUpdate */ public function handle(Incident|Schedule $resource, CreateIncidentUpdateRequestData|CreateScheduleUpdateRequestData $data): Update { - $update = new Update(array_merge(['user_id' => auth()->id()], $data->toArray())); + if ($resource instanceof Incident) { + /** @var CreateIncidentUpdateRequestData $data */ + return IncidentUpdateRecorded::commit( + incident_id: $resource->id, + status: $data->status, + message: $data->message, + user_id: $data->userId ?? auth()->id(), + ); + } - $resource->updates()->save($update); - - // @todo Dispatch notification that incident was updated. - - return $update; + /** @var CreateScheduleUpdateRequestData $data */ + return ScheduleUpdateRecorded::commit( + schedule_id: $resource->id, + message: $data->message, + status: $data->status ?? null, + user_id: auth()->id(), + ); } } diff --git a/src/Filament/Resources/ComponentGroups/Pages/CreateComponentGroup.php b/src/Filament/Resources/ComponentGroups/Pages/CreateComponentGroup.php index cdd4c73a..55fa18c4 100644 --- a/src/Filament/Resources/ComponentGroups/Pages/CreateComponentGroup.php +++ b/src/Filament/Resources/ComponentGroups/Pages/CreateComponentGroup.php @@ -2,10 +2,20 @@ namespace Cachet\Filament\Resources\ComponentGroups\Pages; +use Cachet\Actions\ComponentGroup\CreateComponentGroup as CreateComponentGroupAction; +use Cachet\Data\Requests\ComponentGroup\CreateComponentGroupRequestData; use Cachet\Filament\Resources\ComponentGroups\ComponentGroupResource; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Database\Eloquent\Model; class CreateComponentGroup extends CreateRecord { protected static string $resource = ComponentGroupResource::class; + + protected function handleRecordCreation(array $data): Model + { + $requestData = CreateComponentGroupRequestData::from($data); + + return app(CreateComponentGroupAction::class)->handle($requestData); + } } diff --git a/src/Filament/Resources/ComponentGroups/Pages/EditComponentGroup.php b/src/Filament/Resources/ComponentGroups/Pages/EditComponentGroup.php index b19792c1..9abd0a9d 100644 --- a/src/Filament/Resources/ComponentGroups/Pages/EditComponentGroup.php +++ b/src/Filament/Resources/ComponentGroups/Pages/EditComponentGroup.php @@ -2,9 +2,13 @@ namespace Cachet\Filament\Resources\ComponentGroups\Pages; +use Cachet\Actions\ComponentGroup\UpdateComponentGroup; +use Cachet\Data\Requests\ComponentGroup\UpdateComponentGroupRequestData; use Cachet\Filament\Resources\ComponentGroups\ComponentGroupResource; +use Cachet\Models\ComponentGroup; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; class EditComponentGroup extends EditRecord { @@ -16,4 +20,14 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + /** @var ComponentGroup $record */ + $requestData = UpdateComponentGroupRequestData::from($data); + + app(UpdateComponentGroup::class)->handle($record, $requestData); + + return $record; + } } diff --git a/src/Filament/Resources/Components/Pages/CreateComponent.php b/src/Filament/Resources/Components/Pages/CreateComponent.php index 2efeda23..901f06b0 100644 --- a/src/Filament/Resources/Components/Pages/CreateComponent.php +++ b/src/Filament/Resources/Components/Pages/CreateComponent.php @@ -2,10 +2,20 @@ namespace Cachet\Filament\Resources\Components\Pages; +use Cachet\Actions\Component\CreateComponent as CreateComponentAction; +use Cachet\Data\Requests\Component\CreateComponentRequestData; use Cachet\Filament\Resources\Components\ComponentResource; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Database\Eloquent\Model; class CreateComponent extends CreateRecord { protected static string $resource = ComponentResource::class; + + protected function handleRecordCreation(array $data): Model + { + $requestData = CreateComponentRequestData::from($data); + + return app(CreateComponentAction::class)->handle($requestData); + } } diff --git a/src/Filament/Resources/Components/Pages/EditComponent.php b/src/Filament/Resources/Components/Pages/EditComponent.php index 26f42678..15e5a0e8 100644 --- a/src/Filament/Resources/Components/Pages/EditComponent.php +++ b/src/Filament/Resources/Components/Pages/EditComponent.php @@ -2,9 +2,13 @@ namespace Cachet\Filament\Resources\Components\Pages; +use Cachet\Actions\Component\UpdateComponent; +use Cachet\Data\Requests\Component\UpdateComponentRequestData; use Cachet\Filament\Resources\Components\ComponentResource; +use Cachet\Models\Component; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; class EditComponent extends EditRecord { @@ -16,4 +20,14 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + /** @var Component $record */ + $requestData = UpdateComponentRequestData::from($data); + + app(UpdateComponent::class)->handle($record, $requestData); + + return $record; + } } diff --git a/src/Filament/Resources/Incidents/Pages/CreateIncident.php b/src/Filament/Resources/Incidents/Pages/CreateIncident.php index 63321123..48de14f8 100644 --- a/src/Filament/Resources/Incidents/Pages/CreateIncident.php +++ b/src/Filament/Resources/Incidents/Pages/CreateIncident.php @@ -2,10 +2,20 @@ namespace Cachet\Filament\Resources\Incidents\Pages; +use Cachet\Actions\Incident\CreateIncident as CreateIncidentAction; +use Cachet\Data\Requests\Incident\CreateIncidentRequestData; use Cachet\Filament\Resources\Incidents\IncidentResource; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Database\Eloquent\Model; class CreateIncident extends CreateRecord { protected static string $resource = IncidentResource::class; + + protected function handleRecordCreation(array $data): Model + { + $requestData = CreateIncidentRequestData::from($data); + + return app(CreateIncidentAction::class)->handle($requestData); + } } diff --git a/src/Filament/Resources/Incidents/Pages/EditIncident.php b/src/Filament/Resources/Incidents/Pages/EditIncident.php index 28bf16f1..25806aca 100644 --- a/src/Filament/Resources/Incidents/Pages/EditIncident.php +++ b/src/Filament/Resources/Incidents/Pages/EditIncident.php @@ -2,9 +2,13 @@ namespace Cachet\Filament\Resources\Incidents\Pages; +use Cachet\Actions\Incident\UpdateIncident; +use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; use Cachet\Filament\Resources\Incidents\IncidentResource; +use Cachet\Models\Incident; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; class EditIncident extends EditRecord { @@ -16,4 +20,14 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + /** @var Incident $record */ + $requestData = UpdateIncidentRequestData::from($data); + + app(UpdateIncident::class)->handle($record, $requestData); + + return $record; + } } diff --git a/src/Filament/Resources/Schedules/Pages/CreateSchedule.php b/src/Filament/Resources/Schedules/Pages/CreateSchedule.php index 77edae04..f40c4a6e 100644 --- a/src/Filament/Resources/Schedules/Pages/CreateSchedule.php +++ b/src/Filament/Resources/Schedules/Pages/CreateSchedule.php @@ -2,10 +2,37 @@ namespace Cachet\Filament\Resources\Schedules\Pages; +use Cachet\Actions\Schedule\CreateSchedule as CreateScheduleAction; +use Cachet\Data\Requests\Schedule\CreateScheduleRequestData; +use Cachet\Enums\ComponentStatusEnum; use Cachet\Filament\Resources\Schedules\ScheduleResource; +use Carbon\Carbon; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Database\Eloquent\Model; class CreateSchedule extends CreateRecord { protected static string $resource = ScheduleResource::class; + + protected function handleRecordCreation(array $data): Model + { + // Transform scheduleComponents to the format expected by the action + $components = null; + if (! empty($data['scheduleComponents'])) { + $components = collect($data['scheduleComponents'])->map(fn ($item) => [ + 'id' => $item['component_id'], + 'status' => ComponentStatusEnum::from($item['component_status']), + ])->all(); + } + + $requestData = CreateScheduleRequestData::from([ + 'name' => $data['name'], + 'message' => $data['message'], + 'scheduled_at' => $data['scheduled_at'], + 'completed_at' => $data['completed_at'] ?? null, + 'components' => $components, + ]); + + return app(CreateScheduleAction::class)->handle($requestData); + } } diff --git a/src/Filament/Resources/Schedules/Pages/EditSchedule.php b/src/Filament/Resources/Schedules/Pages/EditSchedule.php index 01904f0c..5708c810 100644 --- a/src/Filament/Resources/Schedules/Pages/EditSchedule.php +++ b/src/Filament/Resources/Schedules/Pages/EditSchedule.php @@ -2,9 +2,13 @@ namespace Cachet\Filament\Resources\Schedules\Pages; +use Cachet\Actions\Schedule\UpdateSchedule; +use Cachet\Data\Requests\Schedule\UpdateScheduleRequestData; use Cachet\Filament\Resources\Schedules\ScheduleResource; +use Cachet\Models\Schedule; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; class EditSchedule extends EditRecord { @@ -17,10 +21,13 @@ protected function getHeaderActions(): array ]; } - protected function mutateFormDataBeforeSave(array $data): array + protected function handleRecordUpdate(Model $record, array $data): Model { - $data['completed_at'] = $data['completed_at'] ?? null; + /** @var Schedule $record */ + $requestData = UpdateScheduleRequestData::from($data); - return $data; + app(UpdateSchedule::class)->handle($record, $requestData); + + return $record; } } diff --git a/src/Filament/Resources/Schedules/ScheduleResource.php b/src/Filament/Resources/Schedules/ScheduleResource.php index 8101a09e..1e7bbfe2 100644 --- a/src/Filament/Resources/Schedules/ScheduleResource.php +++ b/src/Filament/Resources/Schedules/ScheduleResource.php @@ -6,6 +6,7 @@ use Cachet\Data\Requests\ScheduleUpdate\CreateScheduleUpdateRequestData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\ScheduleStatusEnum; +use Cachet\Verbs\Events\Schedules\ScheduleCompleted; use Cachet\Filament\Resources\Schedules\Pages\CreateSchedule; use Cachet\Filament\Resources\Schedules\Pages\EditSchedule; use Cachet\Filament\Resources\Schedules\Pages\ListSchedules; @@ -144,7 +145,10 @@ public static function table(Table $table): Table ->required(), ]) ->color('success') - ->action(fn (Schedule $record, array $data) => $record->update(['completed_at' => $data['completed_at']])), + ->action(fn (Schedule $record, array $data) => ScheduleCompleted::commit( + schedule_id: $record->id, + completed_at: $data['completed_at'], + )), EditAction::make(), ]) ->toolbarActions([ diff --git a/src/Filament/Widgets/Components.php b/src/Filament/Widgets/Components.php index 57558dfa..9184d5e8 100644 --- a/src/Filament/Widgets/Components.php +++ b/src/Filament/Widgets/Components.php @@ -5,6 +5,7 @@ use Cachet\Enums\ComponentStatusEnum; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; +use Cachet\Verbs\Events\Components\ComponentStatusChanged; use Filament\Forms\Components\ToggleButtons; use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Section; @@ -80,7 +81,15 @@ protected function buildToggleButton(Component $component): ToggleButtons ->inline() ->live() ->options(ComponentStatusEnum::class) - ->afterStateUpdated(fn (ComponentStatusEnum $state) => $component->update(['status' => $state])); + ->afterStateUpdated(function (ComponentStatusEnum $state) use ($component) { + if ($component->status !== $state) { + ComponentStatusChanged::commit( + component_id: $component->id, + old_status: $component->status, + new_status: $state, + ); + } + }); } protected function loadVisibleComponentGroups(): Collection diff --git a/src/Models/Component.php b/src/Models/Component.php index b8a46ddc..a522ded8 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -4,9 +4,6 @@ use Cachet\Database\Factories\ComponentFactory; use Cachet\Enums\ComponentStatusEnum; -use Cachet\Events\Components\ComponentCreated; -use Cachet\Events\Components\ComponentDeleted; -use Cachet\Events\Components\ComponentUpdated; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -67,12 +64,6 @@ class Component extends Model 'meta', ]; - protected $dispatchesEvents = [ - 'created' => ComponentCreated::class, - 'deleted' => ComponentDeleted::class, - 'updated' => ComponentUpdated::class, - ]; - /** * Get the group the component belongs to. */ diff --git a/src/Models/Incident.php b/src/Models/Incident.php index a02da2f9..e88f52e5 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -6,9 +6,6 @@ use Cachet\Database\Factories\IncidentFactory; use Cachet\Enums\IncidentStatusEnum; use Cachet\Enums\ResourceVisibilityEnum; -use Cachet\Events\Incidents\IncidentCreated; -use Cachet\Events\Incidents\IncidentDeleted; -use Cachet\Events\Incidents\IncidentUpdated; use Cachet\Filament\Resources\Incidents\IncidentResource; use Carbon\Carbon; use Illuminate\Contracts\Auth\Authenticatable; @@ -73,12 +70,8 @@ class Incident extends Model 'occurred_at' => 'datetime', ]; - /** @var array */ - protected $dispatchesEvents = [ - 'created' => IncidentCreated::class, - 'deleted' => IncidentDeleted::class, - 'updated' => IncidentUpdated::class, - ]; + // Webhook events are now dispatched from Verbs event handlers + // See: src/Verbs/Events/Incidents/ /** @var list */ protected $fillable = [ diff --git a/src/Verbs/Events/ComponentGroups/ComponentGroupCreated.php b/src/Verbs/Events/ComponentGroups/ComponentGroupCreated.php new file mode 100644 index 00000000..83a5b69f --- /dev/null +++ b/src/Verbs/Events/ComponentGroups/ComponentGroupCreated.php @@ -0,0 +1,52 @@ +id = $this->component_group_id; + $state->name = $this->name; + $state->order = $this->order; + $state->collapsed = $this->collapsed; + $state->visible = $this->visible; + $state->deleted = false; + $state->component_ids = []; + } + + public function handle(ComponentGroupState $state): ComponentGroup + { + if (config('verbs.migration_mode', false)) { + return ComponentGroup::find($this->component_group_id); + } + + $group = ComponentGroup::create([ + 'name' => $this->name, + 'order' => $this->order, + 'collapsed' => $this->collapsed, + 'visible' => $this->visible, + ]); + + $this->component_group_id = $group->id; + + return $group; + } +} diff --git a/src/Verbs/Events/ComponentGroups/ComponentGroupDeleted.php b/src/Verbs/Events/ComponentGroups/ComponentGroupDeleted.php new file mode 100644 index 00000000..dd42fc14 --- /dev/null +++ b/src/Verbs/Events/ComponentGroups/ComponentGroupDeleted.php @@ -0,0 +1,35 @@ +component_group_id = $component_group_id; + } + + public function validate(ComponentGroupState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ComponentGroupState $state): void + { + $state->deleted = true; + } + + public function handle(): void + { + $group = ComponentGroup::findOrFail($this->component_group_id); + $group->delete(); + } +} diff --git a/src/Verbs/Events/ComponentGroups/ComponentGroupUpdated.php b/src/Verbs/Events/ComponentGroups/ComponentGroupUpdated.php new file mode 100644 index 00000000..cc88c78d --- /dev/null +++ b/src/Verbs/Events/ComponentGroups/ComponentGroupUpdated.php @@ -0,0 +1,65 @@ +component_group_id = $component_group_id; + } + + public function validate(ComponentGroupState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ComponentGroupState $state): void + { + if ($this->name !== null) { + $state->name = $this->name; + } + if ($this->order !== null) { + $state->order = $this->order; + } + if ($this->collapsed !== null) { + $state->collapsed = $this->collapsed; + } + if ($this->visible !== null) { + $state->visible = $this->visible; + } + } + + public function handle(ComponentGroupState $state): ComponentGroup + { + $group = ComponentGroup::findOrFail($this->component_group_id); + + $updates = array_filter([ + 'name' => $this->name, + 'order' => $this->order, + 'collapsed' => $this->collapsed, + 'visible' => $this->visible, + ], fn ($value) => $value !== null); + + if (! empty($updates)) { + $group->update($updates); + } + + return $group->fresh(); + } +} diff --git a/src/Verbs/Events/Components/ComponentCreated.php b/src/Verbs/Events/Components/ComponentCreated.php new file mode 100644 index 00000000..64f7a2ff --- /dev/null +++ b/src/Verbs/Events/Components/ComponentCreated.php @@ -0,0 +1,79 @@ +id = $this->component_id; + $state->name = $this->name; + $state->description = $this->description; + $state->link = $this->link; + $state->status = $this->status; + $state->order = $this->order; + $state->component_group_id = $this->component_group_id; + $state->enabled = $this->enabled; + $state->meta = $this->meta; + $state->deleted = false; + + $state->status_history[] = [ + 'status' => $this->status, + 'at' => now()->toIso8601String(), + ]; + + if ($this->component_group_id) { + $groupState = ComponentGroupState::load($this->component_group_id); + if (! in_array($this->component_id, $groupState->component_ids)) { + $groupState->component_ids[] = $this->component_id; + } + } + } + + public function handle(ComponentState $state): Component + { + if (config('verbs.migration_mode', false)) { + return Component::find($this->component_id); + } + + $component = Component::create([ + 'name' => $this->name, + 'description' => $this->description, + 'link' => $this->link, + 'status' => $this->status, + 'order' => $this->order, + 'component_group_id' => $this->component_group_id, + 'enabled' => $this->enabled, + 'meta' => $this->meta, + ]); + + $this->component_id = $component->id; + + Verbs::unlessReplaying(fn () => event(new WebhookComponentCreated($component))); + + return $component; + } +} diff --git a/src/Verbs/Events/Components/ComponentDeleted.php b/src/Verbs/Events/Components/ComponentDeleted.php new file mode 100644 index 00000000..e9d5a3e5 --- /dev/null +++ b/src/Verbs/Events/Components/ComponentDeleted.php @@ -0,0 +1,52 @@ +component_id = $component_id; + } + + public function validate(ComponentState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ComponentState $state): void + { + $state->deleted = true; + + // Remove from group + if (isset($state->component_group_id) && $state->component_group_id) { + $groupState = ComponentGroupState::load($state->component_group_id); + $groupState->component_ids = array_values( + array_filter($groupState->component_ids, fn ($id) => $id !== $this->component_id) + ); + } + } + + public function handle(ComponentState $state): void + { + $component = Component::findOrFail($this->component_id); + + // Detach all subscribers before deleting + $component->subscribers()->detach(); + + Verbs::unlessReplaying(fn () => event(new WebhookComponentDeleted($component))); + + $component->delete(); + } +} diff --git a/src/Verbs/Events/Components/ComponentStatusChanged.php b/src/Verbs/Events/Components/ComponentStatusChanged.php new file mode 100644 index 00000000..4e332159 --- /dev/null +++ b/src/Verbs/Events/Components/ComponentStatusChanged.php @@ -0,0 +1,54 @@ +component_id = $component_id; + } + + public function validate(ComponentState $state): bool + { + return ! (isset($state->deleted) && $state->deleted) && $this->old_status !== $this->new_status; + } + + public function apply(ComponentState $state): void + { + $state->status = $this->new_status; + $state->status_history[] = [ + 'status' => $this->new_status, + 'at' => now()->toIso8601String(), + ]; + } + + public function handle(ComponentState $state): Component + { + $component = Component::findOrFail($this->component_id); + + $component->update(['status' => $this->new_status]); + + Verbs::unlessReplaying(fn () => event(new ComponentStatusWasChanged( + $component, + $this->old_status, + $this->new_status + ))); + + return $component->fresh(); + } +} diff --git a/src/Verbs/Events/Components/ComponentUpdated.php b/src/Verbs/Events/Components/ComponentUpdated.php new file mode 100644 index 00000000..6d0c11bb --- /dev/null +++ b/src/Verbs/Events/Components/ComponentUpdated.php @@ -0,0 +1,126 @@ +component_id = $component_id; + } + + public function validate(ComponentState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ComponentState $state): void + { + $oldGroupId = isset($state->component_group_id) ? $state->component_group_id : null; + + if ($this->name !== null) { + $state->name = $this->name; + } + if ($this->description !== null) { + $state->description = $this->description; + } + if ($this->link !== null) { + $state->link = $this->link; + } + if ($this->status !== null) { + $previousStatus = isset($state->status) ? $state->status : null; + if ($previousStatus !== $this->status) { + $state->status_history[] = [ + 'status' => $this->status, + 'at' => now()->toIso8601String(), + ]; + $state->status = $this->status; + } + } + if ($this->order !== null) { + $state->order = $this->order; + } + if ($this->component_group_id !== null) { + $state->component_group_id = $this->component_group_id; + } + if ($this->clear_component_group) { + $state->component_group_id = null; + } + if ($this->enabled !== null) { + $state->enabled = $this->enabled; + } + if ($this->meta !== null) { + $state->meta = $this->meta; + } + + // Update group membership if group changed + $newGroupId = $this->clear_component_group ? null : $this->component_group_id; + if (($newGroupId !== null || $this->clear_component_group) && $oldGroupId !== $newGroupId) { + // Remove from old group + if ($oldGroupId) { + $oldGroupState = ComponentGroupState::load($oldGroupId); + $oldGroupState->component_ids = array_values( + array_filter($oldGroupState->component_ids ?? [], fn ($id) => $id !== $this->component_id) + ); + } + // Add to new group + if ($newGroupId) { + $newGroupState = ComponentGroupState::load($newGroupId); + if (! in_array($this->component_id, $newGroupState->component_ids ?? [])) { + $newGroupState->component_ids[] = $this->component_id; + } + } + } + } + + public function handle(ComponentState $state): Component + { + $component = Component::findOrFail($this->component_id); + + $updates = array_filter([ + 'name' => $this->name, + 'description' => $this->description, + 'link' => $this->link, + 'status' => $this->status, + 'order' => $this->order, + 'component_group_id' => $this->component_group_id, + 'enabled' => $this->enabled, + 'meta' => $this->meta, + ], fn ($value) => $value !== null); + + // Handle explicit clearing of component group + if ($this->clear_component_group) { + $updates['component_group_id'] = null; + } + + if (! empty($updates)) { + $component->update($updates); + } + + Verbs::unlessReplaying(fn () => event(new WebhookComponentUpdated($component))); + + return $component->fresh(); + } +} diff --git a/src/Verbs/Events/Incidents/ComponentAttachedToIncident.php b/src/Verbs/Events/Incidents/ComponentAttachedToIncident.php new file mode 100644 index 00000000..f4ebea86 --- /dev/null +++ b/src/Verbs/Events/Incidents/ComponentAttachedToIncident.php @@ -0,0 +1,52 @@ +incident_id = $incident_id; + $this->component_id = $component_id; + } + + public function validate(IncidentState $incidentState, ComponentState $componentState): bool + { + return ! $incidentState->deleted + && ! $componentState->deleted + && ! isset($incidentState->affected_components[$this->component_id]); + } + + public function apply(IncidentState $incidentState, ComponentState $componentState): void + { + $incidentState->affected_components[$this->component_id] = $this->component_status; + + if (! in_array($this->incident_id, $componentState->incident_ids)) { + $componentState->incident_ids[] = $this->incident_id; + } + } + + public function handle(): void + { + $incident = Incident::findOrFail($this->incident_id); + $incident->components()->attach($this->component_id, [ + 'component_status' => $this->component_status, + ]); + } +} diff --git a/src/Verbs/Events/Incidents/ComponentDetachedFromIncident.php b/src/Verbs/Events/Incidents/ComponentDetachedFromIncident.php new file mode 100644 index 00000000..9c51fcea --- /dev/null +++ b/src/Verbs/Events/Incidents/ComponentDetachedFromIncident.php @@ -0,0 +1,47 @@ +incident_id = $incident_id; + $this->component_id = $component_id; + } + + public function validate(IncidentState $incidentState, ComponentState $componentState): bool + { + return ! $incidentState->deleted + && isset($incidentState->affected_components[$this->component_id]); + } + + public function apply(IncidentState $incidentState, ComponentState $componentState): void + { + unset($incidentState->affected_components[$this->component_id]); + + $componentState->incident_ids = array_values( + array_filter($componentState->incident_ids, fn ($id) => $id !== $this->incident_id) + ); + } + + public function handle(): void + { + $incident = Incident::findOrFail($this->incident_id); + $incident->components()->detach($this->component_id); + } +} diff --git a/src/Verbs/Events/Incidents/IncidentCreated.php b/src/Verbs/Events/Incidents/IncidentCreated.php new file mode 100644 index 00000000..8521227a --- /dev/null +++ b/src/Verbs/Events/Incidents/IncidentCreated.php @@ -0,0 +1,105 @@ + $components + */ + public function __construct( + public string $name, + public IncidentStatusEnum $status, + public string $message, + public ResourceVisibilityEnum $visible = ResourceVisibilityEnum::guest, + public bool $stickied = false, + public bool $notifications = false, + public ?string $occurred_at = null, + public ?int $user_id = null, + public ?string $external_provider = null, + public ?string $external_id = null, + public array $components = [], + public ?string $guid = null, + ) {} + + public function apply(IncidentState $state): void + { + $state->id = $this->incident_id; + $state->guid = $this->guid ?? (string) Str::uuid(); + $state->name = $this->name; + $state->status = $this->status; + $state->message = $this->message; + $state->visible = $this->visible; + $state->stickied = $this->stickied; + $state->notifications = $this->notifications; + $state->occurred_at = $this->occurred_at; + $state->user_id = $this->user_id; + $state->external_provider = $this->external_provider; + $state->external_id = $this->external_id; + $state->deleted = false; + + foreach ($this->components as $component) { + $state->affected_components[$component['id']] = $component['status']; + + $componentState = ComponentState::load($component['id']); + if (! in_array($this->incident_id, $componentState->incident_ids)) { + $componentState->incident_ids[] = $this->incident_id; + } + } + + $state->status_history[] = [ + 'from' => null, + 'to' => $this->status, + 'at' => now()->toIso8601String(), + ]; + } + + public function handle(IncidentState $state): Incident + { + if (config('verbs.migration_mode', false)) { + return Incident::find($this->incident_id); + } + + $incident = Incident::create([ + 'guid' => $state->guid, + 'name' => $this->name, + 'status' => $this->status, + 'message' => $this->message, + 'visible' => $this->visible, + 'stickied' => $this->stickied, + 'notifications' => $this->notifications, + 'occurred_at' => $this->occurred_at, + 'user_id' => $this->user_id, + 'external_provider' => $this->external_provider, + 'external_id' => $this->external_id, + ]); + + $this->incident_id = $incident->id; + + if (! empty($this->components)) { + $pivotData = collect($this->components)->mapWithKeys(fn ($c) => [ + $c['id'] => ['component_status' => $c['status']], + ])->all(); + $incident->components()->sync($pivotData); + } + + Verbs::unlessReplaying(fn () => event(new WebhookIncidentCreated($incident))); + + return $incident; + } +} diff --git a/src/Verbs/Events/Incidents/IncidentDeleted.php b/src/Verbs/Events/Incidents/IncidentDeleted.php new file mode 100644 index 00000000..007a79ae --- /dev/null +++ b/src/Verbs/Events/Incidents/IncidentDeleted.php @@ -0,0 +1,53 @@ +incident_id = $incident_id; + } + + public function validate(IncidentState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(IncidentState $state): void + { + $state->deleted = true; + + // Remove incident from component states + $affectedComponents = isset($state->affected_components) ? $state->affected_components : []; + foreach (array_keys($affectedComponents) as $componentId) { + $componentState = ComponentState::load($componentId); + $componentState->incident_ids = array_values( + array_filter($componentState->incident_ids ?? [], fn ($id) => $id !== $this->incident_id) + ); + } + } + + public function handle(IncidentState $state): void + { + $incident = Incident::findOrFail($this->incident_id); + + Verbs::unlessReplaying(fn () => event(new WebhookIncidentDeleted($incident))); + + // Delete related updates first + $incident->updates()->delete(); + + $incident->delete(); + } +} diff --git a/src/Verbs/Events/Incidents/IncidentUpdateRecorded.php b/src/Verbs/Events/Incidents/IncidentUpdateRecorded.php new file mode 100644 index 00000000..80f18ee3 --- /dev/null +++ b/src/Verbs/Events/Incidents/IncidentUpdateRecorded.php @@ -0,0 +1,65 @@ +incident_id = $incident_id; + } + + public function validate(IncidentState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(IncidentState $state): void + { + $state->updates[] = [ + 'status' => $this->status, + 'message' => $this->message, + 'user_id' => $this->user_id, + 'at' => now()->toIso8601String(), + ]; + + $previousStatus = isset($state->status) ? $state->status : null; + if ($previousStatus !== $this->status) { + $state->status_history[] = [ + 'from' => $previousStatus, + 'to' => $this->status, + 'at' => now()->toIso8601String(), + ]; + $state->status = $this->status; + } + } + + public function handle(IncidentState $state): Update + { + $incident = Incident::findOrFail($this->incident_id); + + $update = new Update([ + 'status' => $this->status, + 'message' => $this->message, + 'user_id' => $this->user_id, + ]); + + $incident->updates()->save($update); + + return $update; + } +} diff --git a/src/Verbs/Events/Incidents/IncidentUpdated.php b/src/Verbs/Events/Incidents/IncidentUpdated.php new file mode 100644 index 00000000..2379cdab --- /dev/null +++ b/src/Verbs/Events/Incidents/IncidentUpdated.php @@ -0,0 +1,97 @@ +incident_id = $incident_id; + } + + public function validate(IncidentState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(IncidentState $state): void + { + if ($this->name !== null) { + $state->name = $this->name; + } + if ($this->message !== null) { + $state->message = $this->message; + } + if ($this->visible !== null) { + $state->visible = $this->visible; + } + if ($this->stickied !== null) { + $state->stickied = $this->stickied; + } + if ($this->notifications !== null) { + $state->notifications = $this->notifications; + } + if ($this->occurred_at !== null) { + $state->occurred_at = $this->occurred_at; + } + if ($this->user_id !== null) { + $state->user_id = $this->user_id; + } + if ($this->status !== null) { + $previousStatus = isset($state->status) ? $state->status : null; + if ($previousStatus !== $this->status) { + $state->status_history[] = [ + 'from' => $previousStatus, + 'to' => $this->status, + 'at' => now()->toIso8601String(), + ]; + $state->status = $this->status; + } + } + } + + public function handle(IncidentState $state): Incident + { + $incident = Incident::findOrFail($this->incident_id); + + $updates = array_filter([ + 'name' => $this->name, + 'status' => $this->status, + 'message' => $this->message, + 'visible' => $this->visible, + 'stickied' => $this->stickied, + 'notifications' => $this->notifications, + 'occurred_at' => $this->occurred_at, + 'user_id' => $this->user_id, + ], fn ($value) => $value !== null); + + if (! empty($updates)) { + $incident->update($updates); + } + + Verbs::unlessReplaying(fn () => event(new WebhookIncidentUpdated($incident))); + + return $incident->fresh(); + } +} diff --git a/src/Verbs/Events/Schedules/ComponentAttachedToSchedule.php b/src/Verbs/Events/Schedules/ComponentAttachedToSchedule.php new file mode 100644 index 00000000..54e487da --- /dev/null +++ b/src/Verbs/Events/Schedules/ComponentAttachedToSchedule.php @@ -0,0 +1,52 @@ +schedule_id = $schedule_id; + $this->component_id = $component_id; + } + + public function validate(ScheduleState $scheduleState, ComponentState $componentState): bool + { + return ! $scheduleState->deleted + && ! $componentState->deleted + && ! isset($scheduleState->affected_components[$this->component_id]); + } + + public function apply(ScheduleState $scheduleState, ComponentState $componentState): void + { + $scheduleState->affected_components[$this->component_id] = $this->component_status; + + if (! in_array($this->schedule_id, $componentState->schedule_ids)) { + $componentState->schedule_ids[] = $this->schedule_id; + } + } + + public function handle(): void + { + $schedule = Schedule::findOrFail($this->schedule_id); + $schedule->components()->attach($this->component_id, [ + 'component_status' => $this->component_status, + ]); + } +} diff --git a/src/Verbs/Events/Schedules/ComponentDetachedFromSchedule.php b/src/Verbs/Events/Schedules/ComponentDetachedFromSchedule.php new file mode 100644 index 00000000..ed69421f --- /dev/null +++ b/src/Verbs/Events/Schedules/ComponentDetachedFromSchedule.php @@ -0,0 +1,47 @@ +schedule_id = $schedule_id; + $this->component_id = $component_id; + } + + public function validate(ScheduleState $scheduleState, ComponentState $componentState): bool + { + return ! $scheduleState->deleted + && isset($scheduleState->affected_components[$this->component_id]); + } + + public function apply(ScheduleState $scheduleState, ComponentState $componentState): void + { + unset($scheduleState->affected_components[$this->component_id]); + + $componentState->schedule_ids = array_values( + array_filter($componentState->schedule_ids, fn ($id) => $id !== $this->schedule_id) + ); + } + + public function handle(): void + { + $schedule = Schedule::findOrFail($this->schedule_id); + $schedule->components()->detach($this->component_id); + } +} diff --git a/src/Verbs/Events/Schedules/ScheduleCompleted.php b/src/Verbs/Events/Schedules/ScheduleCompleted.php new file mode 100644 index 00000000..22677bc6 --- /dev/null +++ b/src/Verbs/Events/Schedules/ScheduleCompleted.php @@ -0,0 +1,43 @@ +schedule_id = $schedule_id; + } + + public function validate(ScheduleState $state): bool + { + $isDeleted = isset($state->deleted) && $state->deleted; + $isCompleted = isset($state->completed_at) && $state->completed_at !== null; + + return ! $isDeleted && ! $isCompleted; + } + + public function apply(ScheduleState $state): void + { + $state->completed_at = $this->completed_at; + } + + public function handle(ScheduleState $state): Schedule + { + $schedule = Schedule::findOrFail($this->schedule_id); + + $schedule->update(['completed_at' => $this->completed_at]); + + return $schedule->fresh(); + } +} diff --git a/src/Verbs/Events/Schedules/ScheduleCreated.php b/src/Verbs/Events/Schedules/ScheduleCreated.php new file mode 100644 index 00000000..4c8c5505 --- /dev/null +++ b/src/Verbs/Events/Schedules/ScheduleCreated.php @@ -0,0 +1,71 @@ + $components + */ + public function __construct( + public string $name, + public ?string $message = null, + public ?string $scheduled_at = null, + public ?string $completed_at = null, + public array $components = [], + ) {} + + public function apply(ScheduleState $state): void + { + $state->id = $this->schedule_id; + $state->name = $this->name; + $state->message = $this->message; + $state->scheduled_at = $this->scheduled_at; + $state->completed_at = $this->completed_at; + $state->deleted = false; + + foreach ($this->components as $component) { + $state->affected_components[$component['id']] = $component['status']; + + $componentState = ComponentState::load($component['id']); + if (! in_array($this->schedule_id, $componentState->schedule_ids)) { + $componentState->schedule_ids[] = $this->schedule_id; + } + } + } + + public function handle(ScheduleState $state): Schedule + { + if (config('verbs.migration_mode', false)) { + return Schedule::find($this->schedule_id); + } + + $schedule = Schedule::create([ + 'name' => $this->name, + 'message' => $this->message, + 'scheduled_at' => $this->scheduled_at, + 'completed_at' => $this->completed_at, + ]); + + $this->schedule_id = $schedule->id; + + if (! empty($this->components)) { + $pivotData = collect($this->components)->mapWithKeys(fn ($c) => [ + $c['id'] => ['component_status' => $c['status']], + ])->all(); + $schedule->components()->sync($pivotData); + } + + return $schedule; + } +} diff --git a/src/Verbs/Events/Schedules/ScheduleDeleted.php b/src/Verbs/Events/Schedules/ScheduleDeleted.php new file mode 100644 index 00000000..89dd544a --- /dev/null +++ b/src/Verbs/Events/Schedules/ScheduleDeleted.php @@ -0,0 +1,49 @@ +schedule_id = $schedule_id; + } + + public function validate(ScheduleState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ScheduleState $state): void + { + $state->deleted = true; + + // Remove schedule from component states + $affectedComponents = isset($state->affected_components) ? $state->affected_components : []; + foreach (array_keys($affectedComponents) as $componentId) { + $componentState = ComponentState::load($componentId); + $componentState->schedule_ids = array_values( + array_filter($componentState->schedule_ids ?? [], fn ($id) => $id !== $this->schedule_id) + ); + } + } + + public function handle(): void + { + $schedule = Schedule::findOrFail($this->schedule_id); + + // Delete related updates first + $schedule->updates()->delete(); + + $schedule->delete(); + } +} diff --git a/src/Verbs/Events/Schedules/ScheduleUpdateRecorded.php b/src/Verbs/Events/Schedules/ScheduleUpdateRecorded.php new file mode 100644 index 00000000..a5ff88c3 --- /dev/null +++ b/src/Verbs/Events/Schedules/ScheduleUpdateRecorded.php @@ -0,0 +1,55 @@ +schedule_id = $schedule_id; + } + + public function validate(ScheduleState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ScheduleState $state): void + { + $state->updates[] = [ + 'status' => $this->status, + 'message' => $this->message, + 'user_id' => $this->user_id, + 'at' => now()->toIso8601String(), + ]; + } + + public function handle(ScheduleState $state): Update + { + $schedule = Schedule::findOrFail($this->schedule_id); + + $update = new Update([ + 'status' => $this->status, + 'message' => $this->message, + 'user_id' => $this->user_id, + ]); + + $schedule->updates()->save($update); + + return $update; + } +} diff --git a/src/Verbs/Events/Schedules/ScheduleUpdated.php b/src/Verbs/Events/Schedules/ScheduleUpdated.php new file mode 100644 index 00000000..9f306ffc --- /dev/null +++ b/src/Verbs/Events/Schedules/ScheduleUpdated.php @@ -0,0 +1,63 @@ +schedule_id = $schedule_id; + } + + public function validate(ScheduleState $state): bool + { + return ! (isset($state->deleted) && $state->deleted); + } + + public function apply(ScheduleState $state): void + { + if ($this->name !== null) { + $state->name = $this->name; + } + if ($this->message !== null) { + $state->message = $this->message; + } + if ($this->scheduled_at !== null) { + $state->scheduled_at = $this->scheduled_at; + } + if ($this->completed_at !== null) { + $state->completed_at = $this->completed_at; + } + } + + public function handle(ScheduleState $state): Schedule + { + $schedule = Schedule::findOrFail($this->schedule_id); + + $updates = array_filter([ + 'name' => $this->name, + 'message' => $this->message, + 'scheduled_at' => $this->scheduled_at, + 'completed_at' => $this->completed_at, + ], fn ($value) => $value !== null); + + if (! empty($updates)) { + $schedule->update($updates); + } + + return $schedule->fresh(); + } +} diff --git a/src/Verbs/States/ComponentGroupState.php b/src/Verbs/States/ComponentGroupState.php new file mode 100644 index 00000000..bdef6fb0 --- /dev/null +++ b/src/Verbs/States/ComponentGroupState.php @@ -0,0 +1,23 @@ + */ + public array $component_ids = []; +} diff --git a/src/Verbs/States/ComponentState.php b/src/Verbs/States/ComponentState.php new file mode 100644 index 00000000..ce0a92dc --- /dev/null +++ b/src/Verbs/States/ComponentState.php @@ -0,0 +1,36 @@ + */ + public array $status_history = []; + + /** @var list */ + public array $incident_ids = []; + + /** @var list */ + public array $schedule_ids = []; +} diff --git a/src/Verbs/States/IncidentState.php b/src/Verbs/States/IncidentState.php new file mode 100644 index 00000000..0df37b93 --- /dev/null +++ b/src/Verbs/States/IncidentState.php @@ -0,0 +1,44 @@ + Component ID => status during incident */ + public array $affected_components = []; + + /** @var array */ + public array $updates = []; + + /** @var array */ + public array $status_history = []; +} diff --git a/src/Verbs/States/ScheduleState.php b/src/Verbs/States/ScheduleState.php new file mode 100644 index 00000000..d1c0409e --- /dev/null +++ b/src/Verbs/States/ScheduleState.php @@ -0,0 +1,26 @@ + Component ID => status during maintenance */ + public array $affected_components = []; + + /** @var array */ + public array $updates = []; +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 0b06bc15..7e3b60ab 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,8 @@ use BladeUI\Icons\BladeIconsServiceProvider; use Dedoc\Scramble\ScrambleServiceProvider; use Filament\FilamentServiceProvider; +use Glhd\Bits\Support\BitsServiceProvider; +use Thunk\Verbs\VerbsServiceProvider; use Filament\Forms\FormsServiceProvider; use Filament\Schemas\SchemasServiceProvider; use Filament\Support\SupportServiceProvider; @@ -41,6 +43,8 @@ protected function getPackageProviders($app) BladeHeroiconsServiceProvider::class, WidgetsServiceProvider::class, ScrambleServiceProvider::class, + BitsServiceProvider::class, + VerbsServiceProvider::class, ]); // Laravel apps register providers in alphabetical order, so we do the same here for consistency.