diff --git a/CLAUDE.md b/CLAUDE.md index f2ae643a..91aa3e62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,3 +122,14 @@ protected function isAccessible(User $user, ?string $path = null): bool ## HTTP Requests - Never use the `request()` helper. Inject `Illuminate\Http\Request` into the controller method and read input from `$request` (e.g. `$request->integer('per_page', 15)`). + +## The Current User + +- Never call `auth()` inside a model or an action. Resolving the current user is the caller's job, and reaching for it deeper down couples domain code to an HTTP session it should know nothing about. +- Models and actions that need a user accept one as a parameter, typed `?Authenticatable`. +- Controllers, Filament pages, jobs, commands and views are the right places to resolve the user, using `Cachet::user()` where a request-bound guard is needed. + +## Transactions + +- Any action that writes more than once — a model plus its pivots, a change plus its audit record — wraps the writes in `DB::transaction()`, so a failure part-way through cannot leave orphaned or half-populated rows. +- Events describing the completed change are dispatched after the transaction commits, never inside it. diff --git a/database/migrations/2026_07_25_000001_create_component_status_changes_table.php b/database/migrations/2026_07_25_000001_create_component_status_changes_table.php new file mode 100644 index 00000000..1c805758 --- /dev/null +++ b/database/migrations/2026_07_25_000001_create_component_status_changes_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedInteger('component_id'); + $table->unsignedTinyInteger('old_status')->nullable(); + $table->unsignedTinyInteger('new_status'); + $table->string('source'); + $table->nullableMorphs('causer'); + $table->text('reason')->nullable(); + $table->timestamps(); + + $table->foreign('component_id')->references('id')->on('components')->cascadeOnDelete(); + $table->index(['component_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('component_status_changes'); + } +}; diff --git a/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php b/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php new file mode 100644 index 00000000..e3399ddd --- /dev/null +++ b/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php @@ -0,0 +1,80 @@ + + */ + private const PIVOTS = [ + 'incident_components' => 'incident_id', + 'schedule_components' => 'schedule_id', + ]; + + /** + * Run the migrations. + * + * Now that these rows decide what the status page displays, a component may + * only be attached to a given incident or schedule once. Duplicates that + * predate the constraint are collapsed to their most severe impact, which + * is the one that would have won anyway. + */ + public function up(): void + { + foreach (self::PIVOTS as $table => $parentKey) { + $this->deduplicate($table, $parentKey); + + Schema::table($table, function (Blueprint $table) use ($parentKey) { + $table->unique([$parentKey, 'component_id']); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::PIVOTS as $table => $parentKey) { + Schema::table($table, function (Blueprint $table) use ($parentKey) { + $table->dropUnique([$parentKey, 'component_id']); + }); + } + } + + /** + * Collapse duplicate rows, keeping the most severe impact of each pair. + */ + private function deduplicate(string $table, string $parentKey): void + { + DB::table($table) + ->select($parentKey, 'component_id') + ->groupBy($parentKey, 'component_id') + ->havingRaw('count(*) > 1') + ->get() + ->each(function (object $duplicate) use ($table, $parentKey) { + $keep = DB::table($table) + ->where($parentKey, $duplicate->{$parentKey}) + ->where('component_id', $duplicate->component_id) + ->get() + ->sortByDesc(fn (object $row) => [ + ComponentStatusEnum::tryFrom((int) $row->component_status)?->severity() ?? 0, + $row->id, + ]) + ->first(); + + DB::table($table) + ->where($parentKey, $duplicate->{$parentKey}) + ->where('component_id', $duplicate->component_id) + ->where('id', '!=', $keep->id) + ->delete(); + }); + } +}; diff --git a/database/migrations/2026_07_25_000003_backfill_incident_status_from_updates.php b/database/migrations/2026_07_25_000003_backfill_incident_status_from_updates.php new file mode 100644 index 00000000..702c4d09 --- /dev/null +++ b/database/migrations/2026_07_25_000003_backfill_incident_status_from_updates.php @@ -0,0 +1,55 @@ +where('updateable_type', Relation::getMorphAlias(Incident::class)) + ->whereNotNull('status') + ->orderBy('created_at') + ->orderBy('id') + ->select(['updateable_id', 'status']) + ->chunk(1000, function ($updates) use (&$statuses): void { + foreach ($updates as $update) { + $statuses[$update->updateable_id] = $update->status; + } + }); + + collect($statuses) + ->groupBy(fn (int $status): int => $status, preserveKeys: true) + ->each(function ($incidents, int $status): void { + $incidents->keys()->chunk(1000)->each(fn ($ids) => DB::table('incidents') + ->whereIn('id', $ids->all()) + ->where(fn ($query) => $query->where('status', '!=', $status)->orWhereNull('status')) + ->update(['status' => $status])); + }); + } + + /** + * Reverse the migrations. + * + * The previous statuses were never stored, so there is nothing to restore. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2026_07_26_000001_backfill_component_status_and_make_it_required.php b/database/migrations/2026_07_26_000001_backfill_component_status_and_make_it_required.php new file mode 100644 index 00000000..d10e979e --- /dev/null +++ b/database/migrations/2026_07_26_000001_backfill_component_status_and_make_it_required.php @@ -0,0 +1,41 @@ +whereNull('status') + ->update(['status' => ComponentStatusEnum::unknown->value]); + + Schema::table('components', function (Blueprint $table) { + $table->unsignedInteger('status')->change(); + }); + } + + /** + * Reverse the migrations. + * + * The original nulls were not preserved, so only the schema nullability can + * be restored here. + */ + public function down(): void + { + Schema::table('components', function (Blueprint $table) { + $table->unsignedInteger('status')->nullable()->change(); + }); + } +}; diff --git a/resources/lang/en/component.php b/resources/lang/en/component.php index cc6c4af2..17b74976 100644 --- a/resources/lang/en/component.php +++ b/resources/lang/en/component.php @@ -39,6 +39,12 @@ 'under_maintenance' => 'Under maintenance', 'unknown' => 'Unknown', ], + 'status_source' => [ + 'manual' => 'Manual', + 'monitor' => 'Monitoring', + 'import' => 'Import', + 'system' => 'System', + ], 'overview' => [ 'operational_components_label' => 'Operational components', 'operational_components_description' => 'Components that are fully operational.', diff --git a/resources/lang/en/schedule.php b/resources/lang/en/schedule.php index 4c06eb84..26acc7b9 100644 --- a/resources/lang/en/schedule.php +++ b/resources/lang/en/schedule.php @@ -42,6 +42,7 @@ 'action_label' => 'Add component', 'header' => 'Affected components', 'component_label' => 'Component', + 'status_label' => 'Status during maintenance', ], ], 'add_update' => [ diff --git a/resources/views/components/component-group.blade.php b/resources/views/components/component-group.blade.php index bb588112..7870b023 100644 --- a/resources/views/components/component-group.blade.php +++ b/resources/views/components/component-group.blade.php @@ -2,7 +2,7 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENT_GROUPS_BEFORE) }} @php($groupStatus = $componentGroup->worstComponentStatus()) -
  • isExpanded()) default-open @endif> +
  • isExpanded(auth()->user())) default-open @endif>