13 KiB
13 KiB
Story 8.10: Admin Notification - System Events
Epic Reference
Epic 8: Email Notification System
User Story
As an admin, I want to be notified of critical system events, So that I can address issues promptly.
Dependencies
- Story 8.1: Email Infrastructure Setup (base template, SMTP configuration, queue setup)
Acceptance Criteria
Events to Notify
- Email delivery failures (SMTP errors, bounces)
- Scheduled job failures (Laravel scheduler)
- Queue job failures
- Critical application errors (database, payment, etc.)
Content Requirements
- Event type and description
- Timestamp (formatted per locale)
- Relevant details (error message, stack trace summary, job name)
- Recommended action (if applicable)
- Environment indicator (production/staging)
Delivery Requirements
- Sent immediately using sync mail driver (not queued)
- Clear subject line indicating urgency with event type
- Rate limited: max 1 notification per error type per 15 minutes
- Uses base email template from Story 8.1
Critical Error Definition
DO Notify for:
- Database connection failures (
QueryExceptionwith connection errors) - Queue connection failures
- Mail delivery failures (
Swift_TransportException, mail exceptions) - Scheduled command failures
- Any exception marked with
ShouldNotifyAdmininterface
DO NOT Notify for:
- Validation exceptions (
ValidationException) - Authentication failures (
AuthenticationException) - Authorization failures (
AuthorizationException) - Model not found (
ModelNotFoundException) - HTTP 404 errors (
NotFoundHttpException) - CSRF token mismatches (
TokenMismatchException)
Technical Notes
Files to Create
app/Notifications/SystemErrorNotification.phpapp/Notifications/QueueFailureNotification.phpapp/Contracts/ShouldNotifyAdmin.php(marker interface)app/Services/AdminNotificationService.php(rate limiting logic)
Files to Modify
bootstrap/app.php(exception handling)app/Providers/AppServiceProvider.php(queue failure listener)
Environment Variables
# Admin notification settings (add to .env.example)
ADMIN_NOTIFICATION_EMAIL=admin@libra.ps
SYSTEM_NOTIFICATIONS_ENABLED=true
ERROR_NOTIFICATION_THROTTLE_MINUTES=15
Configuration
// config/libra.php (add section)
'notifications' => [
'admin_email' => env('ADMIN_NOTIFICATION_EMAIL'),
'system_notifications_enabled' => env('SYSTEM_NOTIFICATIONS_ENABLED', true),
'throttle_minutes' => env('ERROR_NOTIFICATION_THROTTLE_MINUTES', 15),
],
Admin Notification Service
// app/Services/AdminNotificationService.php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use App\Notifications\SystemErrorNotification;
class AdminNotificationService
{
public function notifyError(Throwable $exception): void
{
if (!config('libra.notifications.system_notifications_enabled')) {
return;
}
$errorKey = $this->getErrorKey($exception);
$throttleMinutes = config('libra.notifications.throttle_minutes', 15);
// Rate limiting: skip if we've notified about this error type recently
if (Cache::has($errorKey)) {
return;
}
Cache::put($errorKey, true, now()->addMinutes($throttleMinutes));
$adminEmail = config('libra.notifications.admin_email');
// Send immediately (sync) - not queued
Notification::route('mail', $adminEmail)
->notifyNow(new SystemErrorNotification($exception));
}
protected function getErrorKey(Throwable $exception): string
{
return 'admin_notified:' . md5(get_class($exception) . $exception->getMessage());
}
public function shouldNotifyAdmin(Throwable $exception): bool
{
// Check if exception implements marker interface
if ($exception instanceof \App\Contracts\ShouldNotifyAdmin) {
return true;
}
// Check against excluded exception types
$excludedTypes = [
\Illuminate\Validation\ValidationException::class,
\Illuminate\Auth\AuthenticationException::class,
\Illuminate\Auth\Access\AuthorizationException::class,
\Illuminate\Database\Eloquent\ModelNotFoundException::class,
\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class,
\Illuminate\Session\TokenMismatchException::class,
];
foreach ($excludedTypes as $type) {
if ($exception instanceof $type) {
return false;
}
}
// Check for critical database errors
if ($exception instanceof \Illuminate\Database\QueryException) {
return $this->isCriticalDatabaseError($exception);
}
// Notify for mail exceptions
if ($exception instanceof \Symfony\Component\Mailer\Exception\TransportExceptionInterface) {
return true;
}
return false;
}
protected function isCriticalDatabaseError(\Illuminate\Database\QueryException $e): bool
{
$criticalCodes = ['2002', '1045', '1049']; // Connection refused, access denied, unknown DB
return in_array($e->getCode(), $criticalCodes);
}
}
Exception Handler Integration
// bootstrap/app.php
use App\Services\AdminNotificationService;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->reportable(function (Throwable $e) {
$service = app(AdminNotificationService::class);
if ($service->shouldNotifyAdmin($e)) {
$service->notifyError($e);
}
});
})
System Error Notification
// app/Notifications/SystemErrorNotification.php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Throwable;
class SystemErrorNotification extends Notification
{
public function __construct(
public Throwable $exception
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$environment = app()->environment();
$errorType = class_basename($this->exception);
return (new MailMessage)
->subject("[URGENT] [{$environment}] System Error: {$errorType} - Libra Law Firm")
->greeting('System Alert')
->line('A critical error occurred on the platform.')
->line("**Error Type:** {$errorType}")
->line("**Message:** " . $this->exception->getMessage())
->line("**File:** " . $this->exception->getFile() . ':' . $this->exception->getLine())
->line("**Time:** " . now()->format('Y-m-d H:i:s'))
->line("**Environment:** {$environment}")
->line('Please check the application logs for full stack trace and details.')
->salutation('— Libra System Monitor');
}
}
Queue Failure Notification
// app/Notifications/QueueFailureNotification.php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\Events\JobFailed;
class QueueFailureNotification extends Notification
{
public function __construct(
public JobFailed $event
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$jobName = $this->event->job->resolveName();
$environment = app()->environment();
return (new MailMessage)
->subject("[URGENT] [{$environment}] Queue Job Failed: {$jobName} - Libra Law Firm")
->greeting('Queue Failure Alert')
->line('A queued job has failed.')
->line("**Job:** {$jobName}")
->line("**Queue:** " . $this->event->job->getQueue())
->line("**Error:** " . $this->event->exception->getMessage())
->line("**Time:** " . now()->format('Y-m-d H:i:s'))
->line("**Attempts:** " . $this->event->job->attempts())
->line('Please check the failed_jobs table and application logs for details.')
->salutation('— Libra System Monitor');
}
}
Queue Failure Listener Registration
// app/Providers/AppServiceProvider.php boot() method
use Illuminate\Support\Facades\Queue;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Notification;
use App\Notifications\QueueFailureNotification;
public function boot(): void
{
Queue::failing(function (JobFailed $event) {
if (!config('libra.notifications.system_notifications_enabled')) {
return;
}
$adminEmail = config('libra.notifications.admin_email');
Notification::route('mail', $adminEmail)
->notifyNow(new QueueFailureNotification($event));
});
}
Marker Interface
// app/Contracts/ShouldNotifyAdmin.php
namespace App\Contracts;
/**
* Marker interface for exceptions that should trigger admin notification.
* Implement this interface on custom exceptions that require admin attention.
*/
interface ShouldNotifyAdmin
{
}
Testing Approach
Unit Tests
// tests/Unit/Services/AdminNotificationServiceTest.php
test('shouldNotifyAdmin returns false for validation exceptions', function () {
$service = new AdminNotificationService();
$exception = new \Illuminate\Validation\ValidationException(validator([], []));
expect($service->shouldNotifyAdmin($exception))->toBeFalse();
});
test('shouldNotifyAdmin returns true for exceptions implementing ShouldNotifyAdmin', function () {
$service = new AdminNotificationService();
$exception = new class extends Exception implements \App\Contracts\ShouldNotifyAdmin {};
expect($service->shouldNotifyAdmin($exception))->toBeTrue();
});
test('rate limiting prevents duplicate notifications', function () {
$service = new AdminNotificationService();
$exception = new RuntimeException('Test error');
Notification::fake();
$service->notifyError($exception);
$service->notifyError($exception); // Should be throttled
Notification::assertSentTimes(SystemErrorNotification::class, 1);
});
Feature Tests
// tests/Feature/Notifications/SystemErrorNotificationTest.php
test('critical database error triggers admin notification', function () {
Notification::fake();
// Simulate a critical database error being reported
$exception = new \Illuminate\Database\QueryException(
'mysql',
'SELECT 1',
[],
new \Exception('Connection refused', 2002)
);
$service = app(AdminNotificationService::class);
$service->notifyError($exception);
Notification::assertSentOnDemand(
SystemErrorNotification::class,
function ($notification, $channels, $notifiable) {
return $notifiable->routes['mail'] === config('libra.notifications.admin_email');
}
);
});
test('queue failure triggers admin notification', function () {
Notification::fake();
// Dispatch a job that will fail
$job = new class implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable;
public function handle() { throw new Exception('Test failure'); }
};
// Process the job (will fail and trigger notification)
// Note: This requires queue to be sync for testing
Notification::assertSentOnDemand(QueueFailureNotification::class);
});
test('validation exception does not trigger admin notification', function () {
Notification::fake();
try {
validator([], ['email' => 'required'])->validate();
} catch (\Illuminate\Validation\ValidationException $e) {
$service = app(AdminNotificationService::class);
if ($service->shouldNotifyAdmin($e)) {
$service->notifyError($e);
}
}
Notification::assertNothingSent();
});
test('system error notification contains required information', function () {
$exception = new RuntimeException('Test error message');
$notification = new SystemErrorNotification($exception);
$mail = $notification->toMail(new AnonymousNotifiable());
expect($mail->subject)->toContain('[URGENT]');
expect($mail->subject)->toContain('RuntimeException');
expect((string) $mail->render())->toContain('Test error message');
});
Definition of Done
AdminNotificationServicecreated with rate limitingSystemErrorNotificationcreated and styledQueueFailureNotificationcreated and styledShouldNotifyAdminmarker interface created- Exception handler integration in
bootstrap/app.php - Queue failure listener in
AppServiceProvider - Environment variables documented in
.env.example - Configuration added to
config/libra.php - Notifications sent immediately (sync, not queued)
- Rate limiting prevents notification spam
- Excluded exceptions do not trigger notifications
- Unit tests for
shouldNotifyAdmin()logic - Feature tests for notification triggers
- All tests pass
- Code formatted with Pint
Estimation
Complexity: Medium | Effort: 4 hours
Notes
- Admin email should be configured in production before deployment
- Consider monitoring the
failed_jobstable separately for recurring failures - Rate limiting uses cache - ensure cache driver is configured properly
- In local development, set
SYSTEM_NOTIFICATIONS_ENABLED=falseto avoid noise