diff --git a/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php b/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php new file mode 100644 index 00000000..635dfd2e --- /dev/null +++ b/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php @@ -0,0 +1,70 @@ +timestamp('snoozed_until')->nullable()->index()->after('acknowledged_at'); + } + + if (!Schema::hasColumn('alerts', 'snoozed_by_uuid')) { + $table->foreignUuid('snoozed_by_uuid')->nullable()->after('resolved_by_uuid')->constrained('users', 'uuid')->nullOnDelete(); + } + + if (!Schema::hasColumn('alerts', 'assigned_to_uuid')) { + $table->foreignUuid('assigned_to_uuid')->nullable()->after('snoozed_by_uuid')->constrained('users', 'uuid')->nullOnDelete(); + } + + if (!Schema::hasColumn('alerts', 'planned_at')) { + $table->timestamp('planned_at')->nullable()->index()->after('snoozed_until'); + } + }); + + Schema::table('alerts', function (Blueprint $table) { + $table->index(['company_uuid', 'status', 'snoozed_until'], 'alerts_company_status_snoozed_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('alerts', function (Blueprint $table) { + $table->dropIndex('alerts_company_status_snoozed_index'); + }); + + Schema::table('alerts', function (Blueprint $table) { + foreach (['snoozed_by_uuid', 'assigned_to_uuid'] as $column) { + if (Schema::hasColumn('alerts', $column)) { + $table->dropConstrainedForeignId($column); + } + } + + foreach (['planned_at', 'snoozed_until'] as $column) { + if (Schema::hasColumn('alerts', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/src/Models/Alert.php b/src/Models/Alert.php index b84a5f3a..6c49044f 100644 --- a/src/Models/Alert.php +++ b/src/Models/Alert.php @@ -81,6 +81,10 @@ class Alert extends Model 'resolved_at', 'acknowledged_by_uuid', 'resolved_by_uuid', + 'snoozed_until', + 'snoozed_by_uuid', + 'assigned_to_uuid', + 'planned_at', 'meta', ]; @@ -93,8 +97,10 @@ class Alert extends Model 'subject_name', 'acknowledged_by_name', 'resolved_by_name', + 'assigned_to_name', 'is_acknowledged', 'is_resolved', + 'is_snoozed', 'duration_minutes', 'age_minutes', ]; @@ -104,7 +110,7 @@ class Alert extends Model * * @var array */ - protected $hidden = ['subject', 'acknowledgedBy', 'resolvedBy']; + protected $hidden = ['subject', 'acknowledgedBy', 'resolvedBy', 'snoozedBy', 'assignedTo']; /** * The attributes that should be cast to native types. @@ -117,6 +123,8 @@ class Alert extends Model 'triggered_at' => 'datetime', 'acknowledged_at' => 'datetime', 'resolved_at' => 'datetime', + 'snoozed_until' => 'datetime', + 'planned_at' => 'datetime', 'meta' => Json::class, ]; @@ -159,6 +167,16 @@ public function resolvedBy(): BelongsTo return $this->belongsTo(User::class, 'resolved_by_uuid', 'uuid'); } + public function snoozedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'snoozed_by_uuid', 'uuid'); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to_uuid', 'uuid'); + } + public function subject(): MorphTo { return $this->morphTo(); @@ -192,6 +210,22 @@ public function getResolvedByNameAttribute(): ?string return $this->resolvedBy?->name; } + /** + * Get the name of the user the alert is assigned to. + */ + public function getAssignedToNameAttribute(): ?string + { + return $this->assignedTo?->name; + } + + /** + * Whether the alert is snoozed right now. + */ + public function getIsSnoozedAttribute(): bool + { + return $this->isSnoozed(); + } + /** * Check if the alert has been acknowledged. */ @@ -302,6 +336,32 @@ public function scopeUnacknowledged($query) return $query->whereNull('acknowledged_at'); } + /** + * Scope to alerts whose snooze has not ended yet. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeSnoozed($query) + { + return $query->whereNotNull('snoozed_until')->where('snoozed_until', '>', now()); + } + + /** + * Scope to alerts that still need someone: not resolved and not snoozed. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) + { + return $query->where('status', '!=', 'resolved')->where(function ($query) { + $query->whereNull('snoozed_until')->orWhere('snoozed_until', '<=', now()); + }); + } + /** * Scope to get critical alerts. * @@ -337,10 +397,16 @@ public function acknowledge(?User $user = null): bool $user = $user ?? auth()->user(); - $updated = $this->update([ + $updateData = [ 'acknowledged_at' => now(), 'acknowledged_by_uuid' => $user?->uuid, - ]); + ]; + + if ($this->status === 'open') { + $updateData['status'] = 'acknowledged'; + } + + $updated = $this->update($updateData); if ($updated) { activity('alert_acknowledged') @@ -438,16 +504,25 @@ public function escalate(string $newSeverity, ?string $reason = null): bool /** * Snooze the alert for a specified duration. + * + * The wake time lives in `snoozed_until` so a queue can exclude snoozed + * alerts in the query; the reason stays in `meta` as before. */ - public function snooze(int $minutes, ?string $reason = null): bool + public function snooze(int $minutes, ?string $reason = null, ?User $user = null): bool { $snoozeUntil = now()->addMinutes($minutes); + $actor = auth()->user(); + $user = $user ?? ($actor instanceof User ? $actor : null); $meta = $this->meta ?? []; - $meta['snoozed_until'] = $snoozeUntil; $meta['snooze_reason'] = $reason; + unset($meta['snoozed_until']); - $updated = $this->update(['meta' => $meta]); + $updated = $this->update([ + 'snoozed_until' => $snoozeUntil, + 'snoozed_by_uuid' => $user?->uuid, + 'meta' => $meta, + ]); if ($updated) { activity('alert_snoozed') @@ -456,6 +531,7 @@ public function snooze(int $minutes, ?string $reason = null): bool 'snoozed_for_minutes' => $minutes, 'snoozed_until' => $snoozeUntil, 'reason' => $reason, + 'snoozed_by' => $user?->name, ]) ->log('Alert snoozed'); } @@ -463,13 +539,55 @@ public function snooze(int $minutes, ?string $reason = null): bool return $updated; } + /** + * End a snooze early so the alert is active again. + */ + public function unsnooze(): bool + { + if (!$this->snoozed_until) { + return false; + } + + $updated = $this->update([ + 'snoozed_until' => null, + 'snoozed_by_uuid' => null, + ]); + + if ($updated) { + activity('alert_unsnoozed') + ->performedOn($this) + ->log('Alert snooze ended'); + } + + return $updated; + } + + /** + * Give the alert an owner (or clear it with null). + */ + public function assignTo(?User $user): bool + { + $updated = $this->update(['assigned_to_uuid' => $user?->uuid]); + + if ($updated) { + activity('alert_assigned') + ->performedOn($this) + ->withProperties(['assigned_to' => $user?->name]) + ->log($user ? 'Alert assigned' : 'Alert unassigned'); + } + + return $updated; + } + /** * Check if the alert is currently snoozed. + * + * Reads the column, falling back to the `meta.snoozed_until` value older + * rows were written with before the column existed. */ public function isSnoozed(): bool { - $meta = $this->meta ?? []; - $snoozeUntil = $meta['snoozed_until'] ?? null; + $snoozeUntil = $this->snoozed_until ?? ($this->meta['snoozed_until'] ?? null); if (!$snoozeUntil) { return false; diff --git a/tests/Unit/Models/OperationalModelsTest.php b/tests/Unit/Models/OperationalModelsTest.php index 1c72b4fd..4978d81d 100644 --- a/tests/Unit/Models/OperationalModelsTest.php +++ b/tests/Unit/Models/OperationalModelsTest.php @@ -201,6 +201,10 @@ public function clear(): bool $table->timestamp('resolved_at')->nullable(); $table->string('acknowledged_by_uuid')->nullable(); $table->string('resolved_by_uuid')->nullable(); + $table->string('snoozed_by_uuid')->nullable(); + $table->string('assigned_to_uuid')->nullable(); + $table->timestamp('snoozed_until')->nullable(); + $table->timestamp('planned_at')->nullable(); $table->text('meta')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -292,8 +296,12 @@ public function clear(): bool expect($alert->getActivitylogOptions()->logAttributes)->toBe(['*']) ->and($alert->acknowledgedBy()->getForeignKeyName())->toBe('acknowledged_by_uuid') ->and($alert->resolvedBy()->getForeignKeyName())->toBe('resolved_by_uuid') + ->and($alert->snoozedBy()->getForeignKeyName())->toBe('snoozed_by_uuid') + ->and($alert->assignedTo()->getForeignKeyName())->toBe('assigned_to_uuid') ->and($alert->subject()->getMorphType())->toBe('subject_type') ->and($alert->subject_name)->toBeNull() + ->and($alert->assigned_to_name)->toBeNull() + ->and($alert->is_snoozed)->toBeFalse() ->and($alert->duration_minutes)->toBeNull() ->and($alert->age_minutes)->toBe(15) ->and($alert->isSnoozed())->toBeFalse() @@ -326,6 +334,7 @@ public function clear(): bool expect($alert->acknowledge($user))->toBeTrue() ->and($alert->updates[0]['acknowledged_at']->toISOString())->toBe('2026-07-17T12:00:00.000000Z') ->and($alert->updates[0]['acknowledged_by_uuid'])->toBe('user-1') + ->and($alert->updates[0]['status'])->toBe('acknowledged') ->and($alert->acknowledge($user))->toBeFalse() ->and($alert->resolve($user, 'Sensor recalibrated'))->toBeTrue() ->and($alert->updates[1]['status'])->toBe('resolved') @@ -350,9 +359,79 @@ public function clear(): bool ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['from'])->toBe('low') ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['to'])->toBe('medium') ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['escalated_by'])->toBe('session-user') - ->and($escalatingAlert->snooze(30, 'Awaiting technician'))->toBeTrue() + ->and($escalatingAlert->snooze(30, 'Awaiting technician', $user))->toBeTrue() ->and($escalatingAlert->updates[1]['meta']['snooze_reason'])->toBe('Awaiting technician') - ->and($escalatingAlert->isSnoozed())->toBeTrue(); + ->and($escalatingAlert->updates[1]['snoozed_until']->toISOString())->toBe('2026-07-17T12:30:00.000000Z') + ->and($escalatingAlert->updates[1]['snoozed_by_uuid'])->toBe('user-1') + ->and($escalatingAlert->isSnoozed())->toBeTrue() + ->and($escalatingAlert->is_snoozed)->toBeTrue() + ->and($escalatingAlert->unsnooze())->toBeTrue() + ->and($escalatingAlert->updates[2])->toBe(['snoozed_until' => null, 'snoozed_by_uuid' => null]) + ->and($escalatingAlert->isSnoozed())->toBeFalse() + ->and($escalatingAlert->unsnooze())->toBeFalse() + ->and($escalatingAlert->assignTo($user))->toBeTrue() + ->and($escalatingAlert->updates[3])->toBe(['assigned_to_uuid' => 'user-1']) + ->and($escalatingAlert->assignTo(null))->toBeTrue() + ->and($escalatingAlert->updates[4])->toBe(['assigned_to_uuid' => null]); + + // Rows written before the column existed kept the wake time in meta. + $legacyAlert = new Alert(); + $legacyAlert->setRawAttributes([ + 'uuid' => 'alert-legacy', + 'status' => 'open', + 'meta' => ['snoozed_until' => '2026-07-17 13:00:00'], + ], true); + + expect($legacyAlert->isSnoozed())->toBeTrue(); + + Carbon::setTestNow(); +}); + +it('filters alerts through the snoozed and active scopes', function () { + $capsule = operational_models_database(); + Carbon::setTestNow(Carbon::parse('2026-07-17 12:00:00', 'UTC')); + + $capsule->getConnection('mysql')->table('alerts')->insert([ + [ + 'uuid' => 'alert-open', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'open', + 'snoozed_until' => null, + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-snoozed', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'acknowledged', + 'snoozed_until' => '2026-07-17 14:00:00', + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-woken', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'open', + 'snoozed_until' => '2026-07-17 11:00:00', + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-resolved', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'resolved', + 'snoozed_until' => null, + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + ]); + + expect(Alert::query()->snoozed()->pluck('uuid')->all())->toBe(['alert-snoozed']) + ->and(Alert::query()->active()->pluck('uuid')->all())->toBe(['alert-open', 'alert-woken']); Carbon::setTestNow(); });