reviewed epic 8 stories
This commit is contained in:
@@ -8,35 +8,184 @@ 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
|
||||
- [ ] Scheduled job failures
|
||||
- [ ] Critical application errors
|
||||
- [ ] Email delivery failures (SMTP errors, bounces)
|
||||
- [ ] Scheduled job failures (Laravel scheduler)
|
||||
- [ ] Queue job failures
|
||||
- [ ] Critical application errors (database, payment, etc.)
|
||||
|
||||
### Content
|
||||
### Content Requirements
|
||||
- [ ] Event type and description
|
||||
- [ ] Timestamp
|
||||
- [ ] Relevant details
|
||||
- [ ] Recommended action (if any)
|
||||
- [ ] Timestamp (formatted per locale)
|
||||
- [ ] Relevant details (error message, stack trace summary, job name)
|
||||
- [ ] Recommended action (if applicable)
|
||||
- [ ] Environment indicator (production/staging)
|
||||
|
||||
### Delivery
|
||||
- [ ] Sent immediately (not queued)
|
||||
- [ ] Clear subject line indicating urgency
|
||||
### 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 (`QueryException` with connection errors)
|
||||
- Queue connection failures
|
||||
- Mail delivery failures (`Swift_TransportException`, mail exceptions)
|
||||
- Scheduled command failures
|
||||
- Any exception marked with `ShouldNotifyAdmin` interface
|
||||
|
||||
**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.php`
|
||||
- `app/Notifications/QueueFailureNotification.php`
|
||||
- `app/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
|
||||
```env
|
||||
# Admin notification settings (add to .env.example)
|
||||
ADMIN_NOTIFICATION_EMAIL=admin@libra.ps
|
||||
SYSTEM_NOTIFICATIONS_ENABLED=true
|
||||
ERROR_NOTIFICATION_THROTTLE_MINUTES=15
|
||||
```
|
||||
|
||||
### Configuration
|
||||
```php
|
||||
// In App\Exceptions\Handler or bootstrap/app.php
|
||||
// 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
|
||||
```php
|
||||
// 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
|
||||
```php
|
||||
// bootstrap/app.php
|
||||
use App\Services\AdminNotificationService;
|
||||
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->reportable(function (Throwable $e) {
|
||||
if ($this->shouldNotifyAdmin($e)) {
|
||||
$admin = User::where('user_type', 'admin')->first();
|
||||
$admin?->notify(new SystemErrorNotification($e));
|
||||
$service = app(AdminNotificationService::class);
|
||||
|
||||
if ($service->shouldNotifyAdmin($e)) {
|
||||
$service->notifyError($e);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
### System Error Notification
|
||||
```php
|
||||
// app/Notifications/SystemErrorNotification.php
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Throwable;
|
||||
|
||||
class SystemErrorNotification extends Notification
|
||||
{
|
||||
@@ -51,29 +200,223 @@ class SystemErrorNotification extends Notification
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$environment = app()->environment();
|
||||
$errorType = class_basename($this->exception);
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('[URGENT] System Error - Libra Law Firm')
|
||||
->subject("[URGENT] [{$environment}] System Error: {$errorType} - Libra Law Firm")
|
||||
->greeting('System Alert')
|
||||
->line('A critical error occurred on the platform.')
|
||||
->line('Error: ' . $this->exception->getMessage())
|
||||
->line('Time: ' . now()->format('Y-m-d H:i:s'))
|
||||
->line('Please check the logs for more details.');
|
||||
->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
|
||||
Queue::failing(function (JobFailed $event) {
|
||||
$admin = User::where('user_type', 'admin')->first();
|
||||
$admin?->notify(new QueueFailureNotification($event));
|
||||
### Queue Failure Notification
|
||||
```php
|
||||
// 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
|
||||
```php
|
||||
// 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
|
||||
```php
|
||||
// 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
|
||||
```php
|
||||
// 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
|
||||
```php
|
||||
// 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
|
||||
- [ ] Email failures notified
|
||||
- [ ] Job failures notified
|
||||
- [ ] Critical errors notified
|
||||
- [ ] Sent immediately
|
||||
- [ ] Clear urgency indication
|
||||
- [ ] Tests pass
|
||||
- [ ] `AdminNotificationService` created with rate limiting
|
||||
- [ ] `SystemErrorNotification` created and styled
|
||||
- [ ] `QueueFailureNotification` created and styled
|
||||
- [ ] `ShouldNotifyAdmin` marker 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:** 3 hours
|
||||
**Complexity:** Medium | **Effort:** 4 hours
|
||||
|
||||
## Notes
|
||||
- Admin email should be configured in production before deployment
|
||||
- Consider monitoring the `failed_jobs` table separately for recurring failures
|
||||
- Rate limiting uses cache - ensure cache driver is configured properly
|
||||
- In local development, set `SYSTEM_NOTIFICATIONS_ENABLED=false` to avoid noise
|
||||
|
||||
Reference in New Issue
Block a user