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.