Skip to content

Commit f732929

Browse files
committed
feat(phase-33): smart alert rules with threshold monitoring and notifications
- AlertRule model: name, type (overdue_invoice/low_stock/high_receivables/unresolved_ticket), conditions (json), notification_targets, is_active - AlertEvent model: records each trigger with message + context - AlertEvaluatorService: evaluates each rule type against live DB data - overdue_invoice: finds invoices in 'sent' status past N days - low_stock: finds products with stock_quantity < threshold and reorder_point > 0 - high_receivables: sums outstanding invoices vs threshold - unresolved_ticket: counts open tickets older than N hours - fire() creates AlertEvents + dispatches in-app notifications to target users - EvaluateAlerts console command: evaluates all active rules, runs every 15 min - AlertRuleController: full CRUD + run (evaluate now) + events (history) endpoints - 10 feature tests: listing, creating, type validation, updating, deleting, evaluate detects issues, evaluates clean, fire creates events, manual run, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 2bf70bb commit f732929

9 files changed

Lines changed: 556 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\AlertRule;
6+
use App\Services\AlertEvaluatorService;
7+
use Illuminate\Console\Command;
8+
9+
class EvaluateAlerts extends Command
10+
{
11+
protected $signature = 'alerts:evaluate';
12+
protected $description = 'Evaluate all active alert rules and fire notifications for triggered ones';
13+
14+
public function handle(AlertEvaluatorService $evaluator): int
15+
{
16+
$rules = AlertRule::where('is_active', true)->get();
17+
$fired = 0;
18+
$checked = 0;
19+
20+
foreach ($rules as $rule) {
21+
$triggered = $evaluator->evaluate($rule);
22+
$checked++;
23+
24+
if (! empty($triggered)) {
25+
$evaluator->fire($rule, $triggered);
26+
$fired++;
27+
$this->info("Triggered: {$rule->name}{$triggered[0]['message']}");
28+
}
29+
}
30+
31+
$this->info("Checked {$checked} rule(s), triggered {$fired}.");
32+
33+
return self::SUCCESS;
34+
}
35+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Models\AlertRule;
6+
use App\Models\AlertEvent;
7+
use App\Services\AlertEvaluatorService;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
12+
class AlertRuleController extends ApiController
13+
{
14+
public function index(Request $request): JsonResponse
15+
{
16+
$tenantId = $this->tenantId($request);
17+
$rules = AlertRule::where('tenant_id', $tenantId)
18+
->withCount('events')
19+
->latest()
20+
->get();
21+
22+
return $this->success($rules);
23+
}
24+
25+
public function store(Request $request): JsonResponse
26+
{
27+
$data = $request->validate([
28+
'name' => ['required', 'string', 'max:255'],
29+
'type' => ['required', Rule::in(array_keys(AlertRule::$supportedTypes))],
30+
'conditions' => ['required', 'array'],
31+
'notification_targets' => ['required', 'array', 'min:1'],
32+
'is_active' => ['boolean'],
33+
]);
34+
35+
$rule = AlertRule::create([
36+
...$data,
37+
'tenant_id' => $this->tenantId($request),
38+
'is_active' => $data['is_active'] ?? true,
39+
]);
40+
41+
return $this->success($rule, 201);
42+
}
43+
44+
public function show(Request $request, AlertRule $alertRule): JsonResponse
45+
{
46+
return $this->success($alertRule->load('events'));
47+
}
48+
49+
public function update(Request $request, AlertRule $alertRule): JsonResponse
50+
{
51+
$data = $request->validate([
52+
'name' => ['sometimes', 'string', 'max:255'],
53+
'conditions' => ['sometimes', 'array'],
54+
'notification_targets' => ['sometimes', 'array', 'min:1'],
55+
'is_active' => ['boolean'],
56+
]);
57+
58+
$alertRule->update($data);
59+
60+
return $this->success($alertRule->fresh());
61+
}
62+
63+
public function destroy(AlertRule $alertRule): JsonResponse
64+
{
65+
$alertRule->delete();
66+
return $this->success(['message' => 'Alert rule deleted.']);
67+
}
68+
69+
public function run(AlertRule $alertRule, AlertEvaluatorService $evaluator): JsonResponse
70+
{
71+
$triggered = $evaluator->evaluate($alertRule);
72+
73+
if (! empty($triggered)) {
74+
$evaluator->fire($alertRule, $triggered);
75+
}
76+
77+
return $this->success([
78+
'triggered' => ! empty($triggered),
79+
'events' => $triggered,
80+
]);
81+
}
82+
83+
public function events(AlertRule $alertRule): JsonResponse
84+
{
85+
$events = $alertRule->events()->latest('triggered_at')->limit(50)->get();
86+
return $this->success($events);
87+
}
88+
89+
private function tenantId(Request $request): int
90+
{
91+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
92+
}
93+
}

erp/app/Models/AlertEvent.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class AlertEvent extends Model
9+
{
10+
public $timestamps = false;
11+
12+
protected $fillable = [
13+
'alert_rule_id',
14+
'message',
15+
'context',
16+
'triggered_at',
17+
];
18+
19+
protected $casts = [
20+
'context' => 'array',
21+
'triggered_at' => 'datetime',
22+
];
23+
24+
public function rule(): BelongsTo
25+
{
26+
return $this->belongsTo(AlertRule::class);
27+
}
28+
}

erp/app/Models/AlertRule.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
8+
9+
class AlertRule extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'name',
16+
'type',
17+
'conditions',
18+
'notification_targets',
19+
'is_active',
20+
'last_triggered_at',
21+
];
22+
23+
protected $casts = [
24+
'conditions' => 'array',
25+
'notification_targets' => 'array',
26+
'is_active' => 'boolean',
27+
'last_triggered_at' => 'datetime',
28+
];
29+
30+
public static array $supportedTypes = [
31+
'overdue_invoice' => 'Invoice overdue by N days',
32+
'low_stock' => 'Product stock below threshold',
33+
'high_receivables' => 'Outstanding receivables above amount',
34+
'unresolved_ticket' => 'Helpdesk ticket unresolved for N hours',
35+
'payroll_pending' => 'Payroll run awaiting approval',
36+
];
37+
38+
public function events(): HasMany
39+
{
40+
return $this->hasMany(AlertEvent::class);
41+
}
42+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use App\Models\AlertEvent;
6+
use App\Models\AlertRule;
7+
use App\Modules\Finance\Models\Invoice;
8+
use App\Modules\Inventory\Models\Product;
9+
use App\Modules\HelpDesk\Models\Ticket;
10+
use Illuminate\Support\Facades\DB;
11+
12+
class AlertEvaluatorService
13+
{
14+
public function evaluate(AlertRule $rule): array
15+
{
16+
return match ($rule->type) {
17+
'overdue_invoice' => $this->checkOverdueInvoices($rule),
18+
'low_stock' => $this->checkLowStock($rule),
19+
'high_receivables' => $this->checkHighReceivables($rule),
20+
'unresolved_ticket' => $this->checkUnresolvedTickets($rule),
21+
default => [],
22+
};
23+
}
24+
25+
private function checkOverdueInvoices(AlertRule $rule): array
26+
{
27+
$days = $rule->conditions['days'] ?? 30;
28+
$threshold = now()->subDays($days);
29+
30+
$invoices = Invoice::withoutGlobalScopes()
31+
->where('tenant_id', $rule->tenant_id)
32+
->where('status', 'sent')
33+
->where('due_date', '<', $threshold)
34+
->get(['id', 'number', 'total', 'due_date', 'contact_id']);
35+
36+
if ($invoices->isEmpty()) {
37+
return [];
38+
}
39+
40+
return [[
41+
'message' => "{$invoices->count()} invoice(s) overdue by more than {$days} days",
42+
'context' => [
43+
'count' => $invoices->count(),
44+
'ids' => $invoices->pluck('id')->toArray(),
45+
'numbers' => $invoices->pluck('number')->toArray(),
46+
'total_outstanding' => $invoices->sum('total'),
47+
],
48+
]];
49+
}
50+
51+
private function checkLowStock(AlertRule $rule): array
52+
{
53+
$threshold = $rule->conditions['below'] ?? 10;
54+
55+
$products = Product::withoutGlobalScopes()
56+
->where('tenant_id', $rule->tenant_id)
57+
->where('stock_quantity', '<', $threshold)
58+
->where('reorder_point', '>', 0)
59+
->get(['id', 'name', 'sku', 'stock_quantity']);
60+
61+
if ($products->isEmpty()) {
62+
return [];
63+
}
64+
65+
return [[
66+
'message' => "{$products->count()} product(s) with stock below {$threshold}",
67+
'context' => [
68+
'count' => $products->count(),
69+
'products' => $products->map(fn ($p) => [
70+
'id' => $p->id,
71+
'name' => $p->name,
72+
'sku' => $p->sku,
73+
'quantity' => $p->stock_quantity,
74+
])->toArray(),
75+
],
76+
]];
77+
}
78+
79+
private function checkHighReceivables(AlertRule $rule): array
80+
{
81+
$threshold = $rule->conditions['amount'] ?? 10000;
82+
83+
$total = Invoice::withoutGlobalScopes()
84+
->where('tenant_id', $rule->tenant_id)
85+
->whereNotIn('status', ['paid', 'cancelled'])
86+
->sum('total');
87+
88+
if ($total < $threshold) {
89+
return [];
90+
}
91+
92+
return [[
93+
'message' => "Outstanding receivables (\${$total}) exceed threshold of \${$threshold}",
94+
'context' => ['total_outstanding' => $total, 'threshold' => $threshold],
95+
]];
96+
}
97+
98+
private function checkUnresolvedTickets(AlertRule $rule): array
99+
{
100+
$hours = $rule->conditions['hours'] ?? 48;
101+
$cutoff = now()->subHours($hours);
102+
103+
try {
104+
$count = Ticket::withoutGlobalScopes()
105+
->where('tenant_id', $rule->tenant_id)
106+
->whereNotIn('status', ['resolved', 'closed'])
107+
->where('created_at', '<', $cutoff)
108+
->count();
109+
110+
if ($count === 0) {
111+
return [];
112+
}
113+
114+
return [[
115+
'message' => "{$count} helpdesk ticket(s) unresolved for more than {$hours} hours",
116+
'context' => ['count' => $count, 'hours' => $hours],
117+
]];
118+
} catch (\Throwable) {
119+
return [];
120+
}
121+
}
122+
123+
public function fire(AlertRule $rule, array $triggered): void
124+
{
125+
foreach ($triggered as $alert) {
126+
AlertEvent::create([
127+
'alert_rule_id' => $rule->id,
128+
'message' => $alert['message'],
129+
'context' => $alert['context'] ?? null,
130+
'triggered_at' => now(),
131+
]);
132+
}
133+
134+
$rule->update(['last_triggered_at' => now()]);
135+
136+
// Send in-app notifications
137+
foreach ($rule->notification_targets as $target) {
138+
if (is_int($target)) {
139+
NotificationService::send(
140+
$rule->tenant_id,
141+
$target,
142+
'alert',
143+
"Alert: {$rule->name}",
144+
$triggered[0]['message'] ?? '',
145+
['rule_id' => $rule->id]
146+
);
147+
}
148+
}
149+
}
150+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('alert_rules', function (Blueprint $table) {
11+
$table->id();
12+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
13+
$table->string('name');
14+
$table->string('type'); // overdue_invoice, low_stock, budget_exceeded, high_churn, etc.
15+
$table->json('conditions'); // threshold values and operators
16+
$table->json('notification_targets'); // user IDs or email addresses
17+
$table->boolean('is_active')->default(true);
18+
$table->timestamp('last_triggered_at')->nullable();
19+
$table->timestamps();
20+
});
21+
22+
Schema::create('alert_events', function (Blueprint $table) {
23+
$table->id();
24+
$table->foreignId('alert_rule_id')->constrained('alert_rules')->cascadeOnDelete();
25+
$table->string('message');
26+
$table->json('context')->nullable();
27+
$table->timestamp('triggered_at');
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('alert_events');
34+
Schema::dropIfExists('alert_rules');
35+
}
36+
};

0 commit comments

Comments
 (0)