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 {