Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Give alerts the columns a triage queue needs: when a snooze ends, who
* set it, who owns the alert, and when the owner planned to handle it.
*
* `Alert::snooze()` used to keep `snoozed_until` inside the `meta` JSON,
* which meant a list of open-but-not-snoozed alerts had to load every row
* and ask `isSnoozed()` one at a time. A real column can be indexed and
* queried, and `assigned_to_uuid` gives an alert an owner distinct from
* whoever acknowledged or resolved it. `planned_at` is a time the owner
* chose to deal with it — a scheduling hint, not a due date.
*
* Every column is guarded so the migration is safe to run against a
* table that already has some of them.
*/
public function up(): void
{
Schema::table('alerts', function (Blueprint $table) {
if (!Schema::hasColumn('alerts', 'snoozed_until')) {
$table->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);
}
}
});
}
};
134 changes: 126 additions & 8 deletions src/Models/Alert.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand All @@ -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',
];
Expand All @@ -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.
Expand All @@ -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,
];

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -456,20 +531,63 @@ 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');
}

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;
Expand Down
Loading
Loading