From eb735a9a989fc0506ede3c4570f7e877f1f755c0 Mon Sep 17 00:00:00 2001 From: Stefanescu Mihai Date: Thu, 19 Mar 2026 19:47:06 +0200 Subject: [PATCH] phase 4 --- PLAN.md | 12 +- app/Http/Controllers/AlertRuleController.php | 77 ++++++++++ .../Controllers/NotificationController.php | 40 ++++++ app/Models/AlertRule.php | 42 ++++++ app/Models/NotificationPreference.php | 36 +++++ app/Models/Project.php | 5 + app/Models/User.php | 15 ++ app/Notifications/EventSpikeNotification.php | 63 ++++++++ .../IssueRegressionNotification.php | 61 ++++++++ app/Notifications/NewIssueNotification.php | 61 ++++++++ app/Services/EventProcessor.php | 38 ++++- app/Services/NotificationDispatcher.php | 97 +++++++++++++ ..._03_19_000005_create_alert_rules_table.php | 27 ++++ ..._create_notification_preferences_table.php | 28 ++++ ...3_19_174210_create_notifications_table.php | 31 ++++ resources/views/layouts/navigation.blade.php | 16 +++ resources/views/notifications/index.blade.php | 75 ++++++++++ resources/views/projects/alerts.blade.php | 135 ++++++++++++++++++ routes/web.php | 10 ++ 19 files changed, 860 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/AlertRuleController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Models/AlertRule.php create mode 100644 app/Models/NotificationPreference.php create mode 100644 app/Notifications/EventSpikeNotification.php create mode 100644 app/Notifications/IssueRegressionNotification.php create mode 100644 app/Notifications/NewIssueNotification.php create mode 100644 app/Services/NotificationDispatcher.php create mode 100644 database/migrations/2026_03_19_000005_create_alert_rules_table.php create mode 100644 database/migrations/2026_03_19_000006_create_notification_preferences_table.php create mode 100644 database/migrations/2026_03_19_174210_create_notifications_table.php create mode 100644 resources/views/notifications/index.blade.php create mode 100644 resources/views/projects/alerts.blade.php diff --git a/PLAN.md b/PLAN.md index 28fa8ef..74e6936 100644 --- a/PLAN.md +++ b/PLAN.md @@ -191,14 +191,14 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK ### 4.1 Notification Channels -- [ ] **Email:** on first occurrence of a new issue (not on every event) -- [ ] **Database (in-app):** notification bell with unread count +- [x] **Email:** on first occurrence of a new issue (not on every event) +- [x] **Database (in-app):** notification bell with unread count - [ ] Extensible to Slack/webhooks later ### 4.2 Alert Rules (per project) -- [ ] Notify on: new issue, regression, spike (N events in M minutes) -- [ ] Per-user mute controls +- [x] Notify on: new issue, regression, spike (N events in M minutes) +- [x] Per-user mute controls --- @@ -252,8 +252,8 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK - [x] 13. Status management (resolve, ignore, mute, auto-regression detection) ### Milestone 3 — Notifications & dashboard -- [ ] 14. Email notifications on new issue / regression -- [ ] 15. In-app notification system +- [x] 14. Email notifications on new issue / regression +- [x] 15. In-app notification system - [x] 16. Overview dashboard with charts (events over time) ### Milestone 4 — Production-readiness diff --git a/app/Http/Controllers/AlertRuleController.php b/app/Http/Controllers/AlertRuleController.php new file mode 100644 index 0000000..67fcf88 --- /dev/null +++ b/app/Http/Controllers/AlertRuleController.php @@ -0,0 +1,77 @@ +authorizeProject($request, $project); + + $rules = collect(['new_issue', 'regression', 'spike'])->map(function ($type) use ($project) { + return $project->alertRules()->firstOrCreate( + ['type' => $type], + ['enabled' => true, 'config' => $type === 'spike' ? ['threshold' => 10, 'window_minutes' => 5] : null], + ); + }); + + $preference = $request->user()->notificationPreferenceFor($project); + + return view('projects.alerts', [ + 'project' => $project, + 'rules' => $rules->keyBy('type'), + 'preference' => $preference, + ]); + } + + public function update(Request $request, Project $project): RedirectResponse + { + $this->authorizeProject($request, $project); + + foreach (['new_issue', 'regression', 'spike'] as $type) { + $rule = $project->alertRules()->where('type', $type)->first(); + if ($rule) { + $rule->update(['enabled' => $request->boolean("rules.{$type}.enabled")]); + + if ($type === 'spike') { + $rule->update([ + 'config' => [ + 'threshold' => max(1, (int) $request->input('rules.spike.threshold', 10)), + 'window_minutes' => max(1, (int) $request->input('rules.spike.window_minutes', 5)), + ], + ]); + } + } + } + + return back()->with('status', 'Alert rules updated.'); + } + + public function updatePreference(Request $request, Project $project): RedirectResponse + { + $this->authorizeProject($request, $project); + + $pref = $request->user()->notificationPreferenceFor($project); + $pref->update([ + 'email_enabled' => $request->boolean('email_enabled'), + 'database_enabled' => $request->boolean('database_enabled'), + 'muted' => $request->boolean('muted'), + ]); + + return back()->with('status', 'Notification preferences updated.'); + } + + private function authorizeProject(Request $request, Project $project): void + { + abort_unless( + $request->user()->projects()->where('projects.id', $project->id)->exists(), + 403, + ); + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..cb38984 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,40 @@ +user() + ->notifications() + ->latest() + ->cursorPaginate(20); + + return view('notifications.index', compact('notifications')); + } + + public function markAsRead(Request $request, string $id): RedirectResponse + { + $notification = $request->user()->notifications()->findOrFail($id); + $notification->markAsRead(); + + $data = $notification->data; + if (! empty($data['project_id']) && ! empty($data['issue_id'])) { + return redirect()->route('projects.issues.show', [$data['project_id'], $data['issue_id']]); + } + + return back(); + } + + public function markAllAsRead(Request $request): RedirectResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return back()->with('status', 'All notifications marked as read.'); + } +} diff --git a/app/Models/AlertRule.php b/app/Models/AlertRule.php new file mode 100644 index 0000000..805f48e --- /dev/null +++ b/app/Models/AlertRule.php @@ -0,0 +1,42 @@ + 'boolean', + 'config' => 'array', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function getSpikeThreshold(): int + { + return $this->config['threshold'] ?? 10; + } + + public function getSpikeWindowMinutes(): int + { + return $this->config['window_minutes'] ?? 5; + } +} diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 0000000..27e7f9b --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,36 @@ + 'boolean', + 'database_enabled' => 'boolean', + 'muted' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 34f43a8..856ce38 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -50,6 +50,11 @@ class Project extends Model return $this->hasMany(Event::class); } + public function alertRules(): HasMany + { + return $this->hasMany(AlertRule::class); + } + public function regenerateKeys(): void { $this->update([ diff --git a/app/Models/User.php b/app/Models/User.php index 4ddf6cb..252531c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -37,4 +38,18 @@ class User extends Authenticatable { return $this->projects()->wherePivot('role', 'owner'); } + + public function notificationPreferences(): HasMany + { + return $this->hasMany(NotificationPreference::class); + } + + public function notificationPreferenceFor(Project $project): NotificationPreference + { + return $this->notificationPreferences() + ->firstOrCreate( + ['project_id' => $project->id], + ['email_enabled' => true, 'database_enabled' => true, 'muted' => false], + ); + } } diff --git a/app/Notifications/EventSpikeNotification.php b/app/Notifications/EventSpikeNotification.php new file mode 100644 index 0000000..dd9891d --- /dev/null +++ b/app/Notifications/EventSpikeNotification.php @@ -0,0 +1,63 @@ +notificationPreferenceFor($this->project); + $channels = []; + + if ($pref->database_enabled) { + $channels[] = 'database'; + } + + if ($pref->email_enabled) { + $channels[] = 'mail'; + } + + return $channels; + } + + public function toMail(object $notifiable): MailMessage + { + $url = route('projects.issues.show', [$this->project, $this->issue]); + + return (new MailMessage) + ->subject("[{$this->project->name}] Spike: {$this->eventCount} events in {$this->windowMinutes}m") + ->greeting("Event spike in {$this->project->name}") + ->line("{$this->eventCount} events received in the last {$this->windowMinutes} minutes for:") + ->line($this->issue->title) + ->action('View Issue', $url); + } + + public function toArray(object $notifiable): array + { + return [ + 'type' => 'spike', + 'project_id' => $this->project->id, + 'project_name' => $this->project->name, + 'issue_id' => $this->issue->id, + 'issue_title' => $this->issue->title, + 'event_count' => $this->eventCount, + 'window_minutes' => $this->windowMinutes, + ]; + } +} diff --git a/app/Notifications/IssueRegressionNotification.php b/app/Notifications/IssueRegressionNotification.php new file mode 100644 index 0000000..369c76e --- /dev/null +++ b/app/Notifications/IssueRegressionNotification.php @@ -0,0 +1,61 @@ +notificationPreferenceFor($this->project); + $channels = []; + + if ($pref->database_enabled) { + $channels[] = 'database'; + } + + if ($pref->email_enabled) { + $channels[] = 'mail'; + } + + return $channels; + } + + public function toMail(object $notifiable): MailMessage + { + $url = route('projects.issues.show', [$this->project, $this->issue]); + + return (new MailMessage) + ->subject("[{$this->project->name}] Regression: {$this->issue->title}") + ->greeting("Issue regressed in {$this->project->name}") + ->line("A previously resolved issue has reappeared:") + ->line($this->issue->title) + ->action('View Issue', $url) + ->line("Total occurrences: {$this->issue->times_seen}"); + } + + public function toArray(object $notifiable): array + { + return [ + 'type' => 'regression', + 'project_id' => $this->project->id, + 'project_name' => $this->project->name, + 'issue_id' => $this->issue->id, + 'issue_title' => $this->issue->title, + 'level' => $this->issue->level, + ]; + } +} diff --git a/app/Notifications/NewIssueNotification.php b/app/Notifications/NewIssueNotification.php new file mode 100644 index 0000000..39dda17 --- /dev/null +++ b/app/Notifications/NewIssueNotification.php @@ -0,0 +1,61 @@ +notificationPreferenceFor($this->project); + $channels = []; + + if ($pref->database_enabled) { + $channels[] = 'database'; + } + + if ($pref->email_enabled) { + $channels[] = 'mail'; + } + + return $channels; + } + + public function toMail(object $notifiable): MailMessage + { + $url = route('projects.issues.show', [$this->project, $this->issue]); + + return (new MailMessage) + ->subject("[{$this->project->name}] New issue: {$this->issue->title}") + ->greeting("New issue in {$this->project->name}") + ->line($this->issue->title) + ->when($this->issue->culprit, fn ($mail) => $mail->line("in {$this->issue->culprit}")) + ->action('View Issue', $url) + ->line("Level: {$this->issue->level}"); + } + + public function toArray(object $notifiable): array + { + return [ + 'type' => 'new_issue', + 'project_id' => $this->project->id, + 'project_name' => $this->project->name, + 'issue_id' => $this->issue->id, + 'issue_title' => $this->issue->title, + 'level' => $this->issue->level, + ]; + } +} diff --git a/app/Services/EventProcessor.php b/app/Services/EventProcessor.php index 9936015..4e699f9 100644 --- a/app/Services/EventProcessor.php +++ b/app/Services/EventProcessor.php @@ -12,6 +12,7 @@ class EventProcessor public function __construct( private Fingerprinter $fingerprinter, private PayloadNormalizer $normalizer, + private NotificationDispatcher $notificationDispatcher, ) {} /** @@ -24,7 +25,9 @@ class EventProcessor $fingerprint = $this->fingerprinter->compute($rawPayload); $now = Carbon::now(); - $issue = $this->findOrCreateIssue($project, $data, $fingerprint, $now); + $isNew = false; + $isRegression = false; + $issue = $this->findOrCreateIssue($project, $data, $fingerprint, $now, $isNew, $isRegression); $event = Event::create([ 'issue_id' => $issue->id, @@ -48,6 +51,8 @@ class EventProcessor 'raw_payload' => $rawPayload, ]); + $this->dispatchNotifications($project, $issue, $isNew, $isRegression); + return $event; } @@ -56,17 +61,23 @@ class EventProcessor array $data, string $fingerprint, Carbon $now, + bool &$isNew, + bool &$isRegression, ): Issue { $issue = Issue::where('project_id', $project->id) ->where('fingerprint', $fingerprint) ->first(); if ($issue) { - $this->updateExistingIssue($issue, $data, $now); + $isRegression = $this->updateExistingIssue($issue, $data, $now); + $isNew = false; return $issue; } + $isNew = true; + $isRegression = false; + return $this->createNewIssue($project, $data, $fingerprint, $now); } @@ -101,7 +112,7 @@ class EventProcessor ]); } - private function updateExistingIssue(Issue $issue, array $data, Carbon $now): void + private function updateExistingIssue(Issue $issue, array $data, Carbon $now): bool { $wasResolved = $issue->status === 'resolved'; @@ -110,7 +121,11 @@ class EventProcessor if ($wasResolved) { $issue->markRegression(); + + return true; } + + return false; } private function buildTitle(?array $exception, ?string $message): string @@ -147,4 +162,21 @@ class EventProcessor return $function ? "{$file} in {$function}" : $file; } + + private function dispatchNotifications( + Project $project, + Issue $issue, + bool $isNew, + bool $isRegression, + ): void { + if ($isNew) { + $this->notificationDispatcher->onNewIssue($project, $issue); + } + + if ($isRegression) { + $this->notificationDispatcher->onRegression($project, $issue); + } + + $this->notificationDispatcher->checkSpike($project, $issue); + } } diff --git a/app/Services/NotificationDispatcher.php b/app/Services/NotificationDispatcher.php new file mode 100644 index 0000000..ea7c6df --- /dev/null +++ b/app/Services/NotificationDispatcher.php @@ -0,0 +1,97 @@ +ruleEnabled($project, 'new_issue')) { + return; + } + + $recipients = $this->getRecipients($project); + Notification::send($recipients, new NewIssueNotification($project, $issue)); + } + + public function onRegression(Project $project, Issue $issue): void + { + if (! $this->ruleEnabled($project, 'regression')) { + return; + } + + $recipients = $this->getRecipients($project); + Notification::send($recipients, new IssueRegressionNotification($project, $issue)); + } + + public function checkSpike(Project $project, Issue $issue): void + { + $rule = $project->alertRules() + ->where('type', 'spike') + ->where('enabled', true) + ->first(); + + if (! $rule) { + return; + } + + $threshold = $rule->getSpikeThreshold(); + $windowMinutes = $rule->getSpikeWindowMinutes(); + + $recentCount = Event::where('issue_id', $issue->id) + ->where('created_at', '>=', Carbon::now()->subMinutes($windowMinutes)) + ->count(); + + if ($recentCount < $threshold) { + return; + } + + // Only send once per spike window — check if we already notified + $alreadyNotified = $project->users() + ->first() + ?->notifications() + ->where('type', EventSpikeNotification::class) + ->where('created_at', '>=', Carbon::now()->subMinutes($windowMinutes)) + ->whereJsonContains('data->issue_id', $issue->id) + ->exists(); + + if ($alreadyNotified) { + return; + } + + $recipients = $this->getRecipients($project); + Notification::send( + $recipients, + new EventSpikeNotification($project, $issue, $recentCount, $windowMinutes), + ); + } + + private function ruleEnabled(Project $project, string $type): bool + { + $rule = $project->alertRules()->where('type', $type)->first(); + + // Default: enabled if no rule exists (opt-out model) + return $rule === null || $rule->enabled; + } + + private function getRecipients(Project $project) + { + return $project->users() + ->get() + ->filter(function ($user) use ($project) { + $pref = $user->notificationPreferenceFor($project); + + return ! $pref->muted; + }); + } +} diff --git a/database/migrations/2026_03_19_000005_create_alert_rules_table.php b/database/migrations/2026_03_19_000005_create_alert_rules_table.php new file mode 100644 index 0000000..d36dbd7 --- /dev/null +++ b/database/migrations/2026_03_19_000005_create_alert_rules_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->foreignUlid('project_id')->constrained()->cascadeOnDelete(); + $table->string('type'); // 'new_issue', 'regression', 'spike' + $table->boolean('enabled')->default(true); + $table->json('config')->nullable(); // e.g. {"threshold": 10, "window_minutes": 5} for spike + $table->timestamps(); + + $table->unique(['project_id', 'type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_rules'); + } +}; diff --git a/database/migrations/2026_03_19_000006_create_notification_preferences_table.php b/database/migrations/2026_03_19_000006_create_notification_preferences_table.php new file mode 100644 index 0000000..726617f --- /dev/null +++ b/database/migrations/2026_03_19_000006_create_notification_preferences_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('project_id')->constrained()->cascadeOnDelete(); + $table->boolean('email_enabled')->default(true); + $table->boolean('database_enabled')->default(true); + $table->boolean('muted')->default(false); + $table->timestamps(); + + $table->unique(['user_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_preferences'); + } +}; diff --git a/database/migrations/2026_03_19_174210_create_notifications_table.php b/database/migrations/2026_03_19_174210_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/database/migrations/2026_03_19_174210_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 63682c6..4303dd8 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -21,6 +21,9 @@ Issues + + Alerts + Settings @@ -55,6 +58,19 @@ @endif + {{-- Notification Bell --}} + @php($unreadCount = Auth::user()->unreadNotifications()->count()) + + + + + @if($unreadCount > 0) + + {{ $unreadCount > 99 ? '99+' : $unreadCount }} + + @endif + +