diff --git a/database/factories/IncidentFactory.php b/database/factories/IncidentFactory.php index 596e919d..41e6d037 100644 --- a/database/factories/IncidentFactory.php +++ b/database/factories/IncidentFactory.php @@ -24,6 +24,7 @@ public function definition(): array 'guid' => fake()->uuid(), 'name' => fake()->sentence, 'status' => IncidentStatusEnum::identified->value, + 'baseline_status' => fn (array $attributes) => $attributes['status'] ?? IncidentStatusEnum::identified->value, 'message' => fake()->paragraph, ]; } diff --git a/database/migrations/2026_07_26_000001_add_baseline_status_to_incidents.php b/database/migrations/2026_07_26_000001_add_baseline_status_to_incidents.php new file mode 100644 index 00000000..ebea72e7 --- /dev/null +++ b/database/migrations/2026_07_26_000001_add_baseline_status_to_incidents.php @@ -0,0 +1,46 @@ +unsignedInteger('baseline_status')->nullable()->after('status'); + }); + + DB::table('incidents') + ->whereNotNull('status') + ->update(['baseline_status' => DB::raw('status')]); + + DB::table('incidents') + ->whereNull('status') + ->update(['baseline_status' => IncidentStatusEnum::unknown->value]); + + Schema::table('incidents', function (Blueprint $table) { + $table->unsignedInteger('baseline_status')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('incidents', function (Blueprint $table) { + $table->dropColumn('baseline_status'); + }); + } +}; diff --git a/src/Actions/Incident/CreateIncident.php b/src/Actions/Incident/CreateIncident.php index 7864aa75..54f21f20 100644 --- a/src/Actions/Incident/CreateIncident.php +++ b/src/Actions/Incident/CreateIncident.php @@ -34,6 +34,7 @@ public function handle(CreateIncidentRequestData $data): Incident $incident = DB::transaction(function () use ($data): Incident { return tap(Incident::create(array_merge( ['guid' => Str::uuid()], + ['baseline_status' => $data->status], $data->except('components', 'meta')->toArray() )), function (Incident $incident) use ($data) { if ($data->components) { diff --git a/src/Actions/Incident/SyncIncidentStatus.php b/src/Actions/Incident/SyncIncidentStatus.php index 07e3a244..c4b47377 100644 --- a/src/Actions/Incident/SyncIncidentStatus.php +++ b/src/Actions/Incident/SyncIncidentStatus.php @@ -22,11 +22,9 @@ public function handle(Incident $incident): Incident ->orderByDesc('id') ->value('status'); - if ($status === null) { - return $incident; - } - - $status = $status instanceof IncidentStatusEnum ? $status : IncidentStatusEnum::from((int) $status); + $status = $status === null + ? $incident->baseline_status + : ($status instanceof IncidentStatusEnum ? $status : IncidentStatusEnum::from((int) $status)); if ($incident->status !== $status) { $incident->update(['status' => $status]); diff --git a/src/Actions/Incident/UpdateIncident.php b/src/Actions/Incident/UpdateIncident.php index adeffcc4..e917440c 100644 --- a/src/Actions/Incident/UpdateIncident.php +++ b/src/Actions/Incident/UpdateIncident.php @@ -12,7 +12,13 @@ class UpdateIncident */ public function handle(Incident $incident, UpdateIncidentRequestData $data): Incident { - $incident->update($data->except('meta')->toArray()); + $attributes = $data->except('meta')->toArray(); + + if ($data->status !== null) { + $attributes['baseline_status'] = $data->status; + } + + $incident->update($attributes); if ($data->meta !== null) { $incident->syncMeta($data->meta); diff --git a/src/Data/Requests/IncidentUpdate/EditIncidentUpdateRequestData.php b/src/Data/Requests/IncidentUpdate/EditIncidentUpdateRequestData.php index 70bf485d..1bdb409c 100644 --- a/src/Data/Requests/IncidentUpdate/EditIncidentUpdateRequestData.php +++ b/src/Data/Requests/IncidentUpdate/EditIncidentUpdateRequestData.php @@ -5,20 +5,36 @@ use Cachet\Data\BaseData; use Cachet\Enums\IncidentStatusEnum; use Illuminate\Validation\Rule; +use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Validation\ValidationContext; final class EditIncidentUpdateRequestData extends BaseData { public function __construct( - public readonly ?IncidentStatusEnum $status = null, - public readonly ?string $message = null, + public readonly Optional|IncidentStatusEnum|null $status = new Optional, + public readonly Optional|string $message = new Optional, ) {} public static function rules(ValidationContext $context): array { return [ - 'status' => [Rule::enum(IncidentStatusEnum::class)], + 'status' => ['nullable', Rule::enum(IncidentStatusEnum::class)], 'message' => ['string'], ]; } + + public function toArray(): array + { + $attributes = []; + + if (! $this->status instanceof Optional) { + $attributes['status'] = $this->status; + } + + if (! $this->message instanceof Optional) { + $attributes['message'] = $this->message; + } + + return $attributes; + } } diff --git a/src/Models/Incident.php b/src/Models/Incident.php index 893eddce..3551093b 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -36,6 +36,7 @@ * @property ?int $component_id * @property string $name * @property ?IncidentStatusEnum $status + * @property IncidentStatusEnum $baseline_status * @property string $message * @property ?Carbon $created_at * @property ?Carbon $updated_at @@ -79,6 +80,7 @@ class Incident extends Model implements Metable /** @var array */ protected $casts = [ 'status' => IncidentStatusEnum::class, + 'baseline_status' => IncidentStatusEnum::class, 'visible' => ResourceVisibilityEnum::class, 'stickied' => 'bool', 'scheduled_at' => 'datetime', @@ -104,6 +106,7 @@ class Incident extends Model implements Metable 'component_id', 'name', 'status', + 'baseline_status', 'visible', 'stickied', 'notifications', @@ -120,6 +123,10 @@ protected static function boot() self::creating(function (Incident $model) { $model->guid = Str::uuid(); + if ($model->baseline_status === null) { + $model->baseline_status = $model->status ?? IncidentStatusEnum::unknown; + } + if ($model->published_at === null) { $model->published_at = $model->freshTimestamp(); $model->published_notified_at = $model->freshTimestamp(); @@ -256,15 +263,15 @@ protected function timestamp(): Attribute /** * The incident's status. * - * Retained for backwards compatibility: the status column is canonical and - * is kept in step with the latest status-bearing update at write time, so - * this is simply an alias for it. + * Retained for backwards compatibility: the status column is the current + * status, while `baseline_status` stores what the incident should fall back + * to when its timeline no longer carries a status. * * @return Attribute */ protected function latestStatus(): Attribute { - return Attribute::make(get: fn (): ?IncidentStatusEnum => $this->status); + return Attribute::make(get: fn (): ?IncidentStatusEnum => $this->status ?? $this->baseline_status); } /** diff --git a/tests/Feature/Api/IncidentUpdateTest.php b/tests/Feature/Api/IncidentUpdateTest.php index 25f63ad4..7b2d1a86 100644 --- a/tests/Feature/Api/IncidentUpdateTest.php +++ b/tests/Feature/Api/IncidentUpdateTest.php @@ -1,5 +1,6 @@ create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + 'baseline_status' => IncidentStatusEnum::investigating, + ]); + + $incidentUpdate = $incident->updates()->create([ + 'status' => IncidentStatusEnum::identified, + 'message' => 'We found the issue.', + ]); + + app(SyncIncidentStatus::class)->handle($incident); + + putJson("/status/api/incidents/{$incident->id}/updates/{$incidentUpdate->id}", [ + 'status' => null, + ])->assertOk(); + + expect($incident->fresh()->status)->toBe(IncidentStatusEnum::investigating) + ->and($incidentUpdate->fresh()->status)->toBeNull(); +}); + it('cannot delete an incident update if not authenticated', function () { $incidentUpdate = Update::factory()->forIncident()->create(); diff --git a/tests/Feature/Database/IncidentBaselineStatusBackfillTest.php b/tests/Feature/Database/IncidentBaselineStatusBackfillTest.php new file mode 100644 index 00000000..61e847dd --- /dev/null +++ b/tests/Feature/Database/IncidentBaselineStatusBackfillTest.php @@ -0,0 +1,49 @@ +dropColumn('baseline_status'); + }); +} + +/** + * Run the baseline status migration over whatever rows are present. + */ +function addIncidentBaselineStatus(): void +{ + $migration = require __DIR__.'/../../../database/migrations/2026_07_26_000001_add_baseline_status_to_incidents.php'; + + $migration->up(); +} + +it('backfills the incident baseline status from the current status', function () { + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::watching]); + + dropIncidentBaselineStatus(); + addIncidentBaselineStatus(); + + expect($incident->fresh()->baseline_status)->toBe(IncidentStatusEnum::watching); +}); + +it('backfills unknown when an incident has no current status', function () { + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::watching]); + + DB::table('incidents')->where('id', $incident->id)->update(['status' => null]); + + dropIncidentBaselineStatus(); + addIncidentBaselineStatus(); + + expect($incident->fresh()) + ->status->toBeNull() + ->baseline_status->toBe(IncidentStatusEnum::unknown); +}); diff --git a/tests/Unit/Actions/Incident/CreateIncidentTest.php b/tests/Unit/Actions/Incident/CreateIncidentTest.php index e8e3c92c..1876351c 100644 --- a/tests/Unit/Actions/Incident/CreateIncidentTest.php +++ b/tests/Unit/Actions/Incident/CreateIncidentTest.php @@ -41,7 +41,8 @@ expect($incident) ->name->toBe($data->name) ->message->toBe($data->message) - ->status->toBe($data->status); + ->status->toBe($data->status) + ->baseline_status->toBe($data->status); Event::assertDispatched(IncidentCreated::class, fn ($event) => $event->incident->is($incident)); }); diff --git a/tests/Unit/Actions/Incident/UpdateIncidentTest.php b/tests/Unit/Actions/Incident/UpdateIncidentTest.php index e9976b5b..3ebf2649 100644 --- a/tests/Unit/Actions/Incident/UpdateIncidentTest.php +++ b/tests/Unit/Actions/Incident/UpdateIncidentTest.php @@ -2,6 +2,7 @@ use Cachet\Actions\Incident\UpdateIncident; use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; +use Cachet\Enums\IncidentStatusEnum; use Cachet\Events\Incidents\IncidentUpdated; use Cachet\Models\Incident; @@ -30,3 +31,18 @@ Event::assertDispatched(IncidentUpdated::class, fn (IncidentUpdated $event) => $event->incident->is($incident)); Event::assertDispatchedTimes(IncidentUpdated::class, 1); }); + +it('updates the incident baseline status when the status changes directly', function () { + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + 'baseline_status' => IncidentStatusEnum::investigating, + ]); + + app(UpdateIncident::class)->handle($incident, UpdateIncidentRequestData::from([ + 'status' => IncidentStatusEnum::identified, + ])); + + expect($incident->fresh()) + ->status->toBe(IncidentStatusEnum::identified) + ->baseline_status->toBe(IncidentStatusEnum::identified); +}); diff --git a/tests/Unit/Actions/Update/DeleteUpdateTest.php b/tests/Unit/Actions/Update/DeleteUpdateTest.php index 9e7e7137..1701edea 100644 --- a/tests/Unit/Actions/Update/DeleteUpdateTest.php +++ b/tests/Unit/Actions/Update/DeleteUpdateTest.php @@ -1,6 +1,8 @@ $update->updateable_id, ]); }); + +it('restores the incident baseline status when deleting its last status-bearing update', function () { + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + 'baseline_status' => IncidentStatusEnum::investigating, + ]); + + $update = $incident->updates()->create([ + 'status' => IncidentStatusEnum::identified, + 'message' => 'Investigating further.', + ]); + + app(SyncIncidentStatus::class)->handle($incident); + + expect($incident->fresh()->status)->toBe(IncidentStatusEnum::identified); + + app(DeleteUpdate::class)->handle($update); + + expect($incident->fresh()->status)->toBe(IncidentStatusEnum::investigating); +}); diff --git a/tests/Unit/Actions/Update/EditUpdateTest.php b/tests/Unit/Actions/Update/EditUpdateTest.php index 8ed95f28..f3456aeb 100644 --- a/tests/Unit/Actions/Update/EditUpdateTest.php +++ b/tests/Unit/Actions/Update/EditUpdateTest.php @@ -1,8 +1,11 @@ message->toBe($data->message) ->status->toBe($update->status); }); + +it('restores the incident baseline status when the last status-bearing update is cleared', function () { + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + 'baseline_status' => IncidentStatusEnum::investigating, + ]); + + $update = $incident->updates()->create([ + 'status' => IncidentStatusEnum::identified, + 'message' => 'We found the issue.', + ]); + + app(SyncIncidentStatus::class)->handle($incident); + + expect($incident->fresh()->status)->toBe(IncidentStatusEnum::identified); + + app(EditUpdate::class)->handle($update, EditIncidentUpdateRequestData::from([ + 'status' => null, + ])); + + expect($incident->fresh()->status)->toBe(IncidentStatusEnum::investigating); +}); diff --git a/tests/Unit/Models/IncidentTest.php b/tests/Unit/Models/IncidentTest.php index d20e3e0d..eb6d2c81 100644 --- a/tests/Unit/Models/IncidentTest.php +++ b/tests/Unit/Models/IncidentTest.php @@ -87,7 +87,21 @@ }); it('falls back to its own status without updates', function () { - $incident = Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + 'baseline_status' => IncidentStatusEnum::investigating, + ]); expect($incident->latestStatus)->toBe(IncidentStatusEnum::investigating); }); + +it('falls back to its baseline status when the current status is empty', function () { + $incident = Incident::factory()->create([ + 'status' => IncidentStatusEnum::investigating, + 'baseline_status' => IncidentStatusEnum::investigating, + ]); + + $incident->updateQuietly(['status' => null]); + + expect($incident->fresh()->latestStatus)->toBe(IncidentStatusEnum::investigating); +});