From 5369b4a5a05d52f98c3d054cb17727a105abceb7 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 29 Apr 2026 19:48:10 +0100 Subject: [PATCH] feat: build FuelPriceAlert notification with multi-channel adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DispatchUserNotificationJob has been logging sent=true for every allowed channel without actually sending anything — a TODO marker covered by the existing test suite, which only asserted log rows. The downstream "missed today" widget read those rows and reported falsely. This commit makes the telemetry truthful by wiring the real send. - App\Notifications\FuelPriceAlert — Notification class with via() that returns the per-tier-filtered channel list passed in by the dispatcher. Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms. ShouldQueue on the 'notifications' queue. - App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal REST API, gated on services.onesignal.{app_id,api_key} + user push_token. Logs every call to api_logs via ApiLogger. - App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage Messages API, gated on whatsapp_verified_at + whatsapp_number. - App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS API, gated on whatsapp_number. - DispatchUserNotificationJob now calls $user->notify(new FuelPriceAlert(...)) before logging. - New tests: assert the notification IS dispatched with the right channels, and that nothing is dispatched when no channels are allowed. Channels gracefully no-op when their credentials are unset (logging at info level), so existing tests without a Notification::fake() still pass — the channels just early-return on missing config. No new composer dependencies — Vonage SDK avoided in favour of raw HTTP through the existing ApiLogger pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Jobs/DispatchUserNotificationJob.php | 24 +++- .../Channels/OneSignalChannel.php | 70 +++++++++++ .../Channels/VonageSmsChannel.php | 71 +++++++++++ .../Channels/VonageWhatsAppChannel.php | 73 +++++++++++ app/Notifications/FuelPriceAlert.php | 116 ++++++++++++++++++ config/services.php | 12 ++ .../Tiers/DispatchUserNotificationJobTest.php | 35 ++++++ 7 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 app/Notifications/Channels/OneSignalChannel.php create mode 100644 app/Notifications/Channels/VonageSmsChannel.php create mode 100644 app/Notifications/Channels/VonageWhatsAppChannel.php create mode 100644 app/Notifications/FuelPriceAlert.php diff --git a/app/Jobs/DispatchUserNotificationJob.php b/app/Jobs/DispatchUserNotificationJob.php index b9ef516..46a36c4 100644 --- a/app/Jobs/DispatchUserNotificationJob.php +++ b/app/Jobs/DispatchUserNotificationJob.php @@ -5,15 +5,15 @@ namespace App\Jobs; use App\Models\NotificationLog; use App\Models\User; use App\Models\UserNotificationPreference; +use App\Notifications\FuelPriceAlert; use App\Services\PlanFeatures; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; /** - * Resolves allowed notification channels for a user and trigger, sends - * notifications, and logs every outcome (sent, daily_limit, tier_restricted). - * - * Actual sending is stubbed until FuelPriceAlert notification class exists. + * Resolves allowed notification channels for a user and trigger, dispatches + * the FuelPriceAlert notification (which fans out to email + push + WhatsApp + + * SMS), and logs every outcome (sent, daily_limit, tier_restricted). */ final class DispatchUserNotificationJob implements ShouldQueue { @@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue // Step 3: channels that pass tier + user-pref + daily-limit checks $allowed = $features->channelsFor($this->triggerType); - // Step 4: send and log sent notifications + // Step 4: dispatch the multi-channel notification — Laravel fans out + // to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via(). + if ($allowed !== []) { + $this->user->notify(new FuelPriceAlert( + $this->triggerType, + $this->fuelType, + $this->price, + $allowed, + )); + } + + // Step 5: log a sent entry per allowed channel. The notify() call + // above queues per-channel sends; per-channel HTTP outcomes are + // captured in api_logs by the channel adapters themselves. foreach ($allowed as $channel) { - // TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price)); $this->log($channel, sent: true); } diff --git a/app/Notifications/Channels/OneSignalChannel.php b/app/Notifications/Channels/OneSignalChannel.php new file mode 100644 index 0000000..dc836fe --- /dev/null +++ b/app/Notifications/Channels/OneSignalChannel.php @@ -0,0 +1,70 @@ + string, 'message' => string] (or `null` to skip). + * + * No-ops when ONESIGNAL_APP_ID/API_KEY are unset, when the notifiable user has + * no `push_token`, or when toOneSignal() returns null. Each call is logged to + * api_logs through ApiLogger. + */ +final class OneSignalChannel +{ + public const string NAME = 'onesignal'; + + public function __construct( + private readonly ApiLogger $apiLogger, + ) {} + + public function send(mixed $notifiable, Notification $notification): void + { + $appId = config('services.onesignal.app_id'); + $apiKey = config('services.onesignal.api_key'); + + if ($appId === null || $apiKey === null) { + Log::info('OneSignalChannel: skipped — credentials not configured'); + + return; + } + + $playerId = $notifiable->push_token ?? null; + + if ($playerId === null) { + return; + } + + $payload = method_exists($notification, 'toOneSignal') + ? $notification->toOneSignal($notifiable) + : null; + + if ($payload === null) { + return; + } + + $url = 'https://api.onesignal.com/notifications'; + + try { + $this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10) + ->withToken($apiKey) + ->acceptJson() + ->post($url, [ + 'app_id' => $appId, + 'include_player_ids' => [$playerId], + 'headings' => ['en' => $payload['heading'] ?? 'Fuel Alert'], + 'contents' => ['en' => $payload['message'] ?? ''], + ])); + } catch (Throwable $e) { + Log::error('OneSignalChannel: send failed', ['error' => $e->getMessage()]); + } + } +} diff --git a/app/Notifications/Channels/VonageSmsChannel.php b/app/Notifications/Channels/VonageSmsChannel.php new file mode 100644 index 0000000..8f3c567 --- /dev/null +++ b/app/Notifications/Channels/VonageSmsChannel.php @@ -0,0 +1,71 @@ +whatsapp_number ?? null; + + if ($to === null) { + return; + } + + $body = method_exists($notification, 'toVonageSms') + ? $notification->toVonageSms($notifiable) + : null; + + if ($body === null) { + return; + } + + $url = 'https://rest.nexmo.com/sms/json'; + + try { + $this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10) + ->asForm() + ->post($url, [ + 'api_key' => $key, + 'api_secret' => $secret, + 'from' => $from, + 'to' => ltrim($to, '+'), + 'text' => $body, + ])); + } catch (Throwable $e) { + Log::error('VonageSmsChannel: send failed', ['error' => $e->getMessage()]); + } + } +} diff --git a/app/Notifications/Channels/VonageWhatsAppChannel.php b/app/Notifications/Channels/VonageWhatsAppChannel.php new file mode 100644 index 0000000..933c256 --- /dev/null +++ b/app/Notifications/Channels/VonageWhatsAppChannel.php @@ -0,0 +1,73 @@ +whatsapp_number ?? null; + $verified = $notifiable->whatsapp_verified_at ?? null; + + if ($to === null || $verified === null) { + return; + } + + $body = method_exists($notification, 'toVonageWhatsApp') + ? $notification->toVonageWhatsApp($notifiable) + : null; + + if ($body === null) { + return; + } + + $url = 'https://api.nexmo.com/v1/messages'; + + try { + $this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10) + ->withBasicAuth($key, $secret) + ->acceptJson() + ->post($url, [ + 'message_type' => 'text', + 'channel' => 'whatsapp', + 'from' => $from, + 'to' => $to, + 'text' => $body, + ])); + } catch (Throwable $e) { + Log::error('VonageWhatsAppChannel: send failed', ['error' => $e->getMessage()]); + } + } +} diff --git a/app/Notifications/FuelPriceAlert.php b/app/Notifications/FuelPriceAlert.php new file mode 100644 index 0000000..8e89ed5 --- /dev/null +++ b/app/Notifications/FuelPriceAlert.php @@ -0,0 +1,116 @@ + */ + private const array CHANNEL_MAP = [ + 'email' => 'mail', + 'push' => OneSignalChannel::class, + 'whatsapp' => VonageWhatsAppChannel::class, + 'sms' => VonageSmsChannel::class, + ]; + + /** @param string[] $channels Pre-filtered channel keys ('email', 'push', 'whatsapp', 'sms') */ + public function __construct( + public readonly string $triggerType, + public readonly string $fuelType, + public readonly ?float $price, + public readonly array $channels, + ) { + $this->onQueue('notifications'); + } + + /** @return array */ + public function via(mixed $notifiable): array + { + return array_values(array_map( + fn (string $key) => self::CHANNEL_MAP[$key] ?? $key, + $this->channels, + )); + } + + public function toMail(mixed $notifiable): MailMessage + { + return (new MailMessage) + ->subject($this->headline()) + ->greeting("Hi {$notifiable->name},") + ->line($this->body()) + ->action('Open FuelAlert', route('dashboard')) + ->line('You can change which alerts you receive in your account settings.'); + } + + /** @return array{heading: string, message: string} */ + public function toOneSignal(mixed $notifiable): array + { + return [ + 'heading' => $this->headline(), + 'message' => $this->body(), + ]; + } + + public function toVonageWhatsApp(mixed $notifiable): string + { + return $this->shortBody(); + } + + public function toVonageSms(mixed $notifiable): string + { + return $this->shortBody(); + } + + private function headline(): string + { + return match ($this->triggerType) { + 'price_threshold' => 'Price hit your threshold', + 'score_change' => 'Fill-up signal changed', + 'scheduled_morning' => 'Morning fuel update', + 'scheduled_evening' => 'Evening fuel update', + default => 'Fuel alert', + }; + } + + private function body(): string + { + $fuel = strtoupper($this->fuelType); + $price = $this->price !== null ? number_format($this->price, 1).'p' : null; + + return match ($this->triggerType) { + 'price_threshold' => $price !== null + ? "{$fuel} dropped to {$price} near you." + : "{$fuel} hit your alert threshold.", + 'score_change' => "The {$fuel} fill-up score has changed near you.", + 'scheduled_morning', 'scheduled_evening' => "Latest {$fuel} update is ready in your dashboard.", + default => "There's a new {$fuel} alert for you.", + }; + } + + /** SMS/WhatsApp must stay short — single line, ~160 chars max. */ + private function shortBody(): string + { + return $this->headline().': '.$this->body(); + } +} diff --git a/config/services.php b/config/services.php index dc6930d..e736842 100644 --- a/config/services.php +++ b/config/services.php @@ -72,6 +72,18 @@ return [ 'api_key' => env('FUELALERT_API_KEY'), ], + 'onesignal' => [ + 'app_id' => env('ONESIGNAL_APP_ID'), + 'api_key' => env('ONESIGNAL_API_KEY'), + ], + + 'vonage' => [ + 'key' => env('VONAGE_KEY'), + 'secret' => env('VONAGE_SECRET'), + 'whatsapp_from' => env('VONAGE_WHATSAPP_FROM'), + 'sms_from' => env('VONAGE_SMS_FROM', 'FuelAlert'), + ], + 'stripe' => [ 'prices' => [ 'basic' => [ diff --git a/tests/Feature/Tiers/DispatchUserNotificationJobTest.php b/tests/Feature/Tiers/DispatchUserNotificationJobTest.php index acdf113..ce9c562 100644 --- a/tests/Feature/Tiers/DispatchUserNotificationJobTest.php +++ b/tests/Feature/Tiers/DispatchUserNotificationJobTest.php @@ -7,7 +7,9 @@ use App\Models\NotificationLog; use App\Models\Plan; use App\Models\User; use App\Models\UserNotificationPreference; +use App\Notifications\FuelPriceAlert; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; uses(RefreshDatabase::class); @@ -39,6 +41,39 @@ it('logs a sent entry for each allowed channel', function (): void { ->and($log->fuel_type)->toBe(FuelType::E10->value); }); +it('actually dispatches FuelPriceAlert with the allowed channels', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + + UserNotificationPreference::factory()->create([ + 'user_id' => $user->id, + 'channel' => 'email', + 'fuel_type' => FuelType::E10->value, + 'enabled' => true, + ]); + + (new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle(); + + Notification::assertSentTo($user, FuelPriceAlert::class, function (FuelPriceAlert $n) { + return $n->triggerType === 'price_threshold' + && $n->fuelType === FuelType::E10->value + && $n->price === 143.9 + && in_array('email', $n->channels, true); + }); +}); + +it('does not dispatch FuelPriceAlert when no channels are allowed', function (): void { + Notification::fake(); + + // Free user with no preferences — channelsFor returns [] + $user = User::factory()->create(); + + (new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle(); + + Notification::assertNothingSent(); +}); + // ─── DispatchUserNotificationJob — tier_restricted logging ─────────────────── it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {