Skip to content

Commit b642b60

Browse files
authored
Merge pull request #25 from YieldStudio/bugfix/avoid-sending-more-than-push-notif-limit-no-batched-notifications
bugfix: adds tests and fix bug sending more than 100 not batched notifications
2 parents 0dff9af + ec45977 commit b642b60

12 files changed

+471
-58
lines changed

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828
"require": {
2929
"php": "^8.1",
3030
"illuminate/database": "^9|^10",
31-
"illuminate/support": "^9|^10"
31+
"illuminate/support": "^9|^10",
32+
"nesbot/carbon": ">=2.62.1"
3233
},
3334
"require-dev": {
3435
"ciareis/bypass": "^1.0",
3536
"dg/bypass-finals": "^1.4",
37+
"guzzlehttp/guzzle": "^7.8",
3638
"laravel/pint": "^1.3",
3739
"orchestra/testbench": "7.*|8.*",
3840
"pestphp/pest": "^1.21",

phpunit.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,9 @@
1919
<directory>src/</directory>
2020
</whitelist>
2121
</filter>
22+
<php>
23+
<env name="APP_KEY" value="AckfSECXIvnK5r28GVIWUAxmbBSjTsmF"/>
24+
<env name="DB_CONNECTION" value="testing"/>
25+
<env name="ENABLE_HTTPS_SUPPORT" value="false"/>
26+
</php>
2227
</phpunit>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace YieldStudio\LaravelExpoNotifier\Contracts;
6+
7+
use Illuminate\Support\Collection;
8+
use YieldStudio\LaravelExpoNotifier\Dto\ExpoMessage;
9+
10+
interface ExpoNotificationsServiceInterface
11+
{
12+
public function __construct(
13+
string $apiUrl,
14+
string $host,
15+
ExpoPendingNotificationStorageInterface $notificationStorage,
16+
ExpoTicketStorageInterface $ticketStorage
17+
);
18+
19+
public function notify(ExpoMessage|Collection|array $expoMessages): Collection;
20+
21+
public function receipts(Collection|array $tokenIds): Collection;
22+
23+
public function getNotificationChunks(): Collection;
24+
}

src/ExpoNotificationsChannel.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
namespace YieldStudio\LaravelExpoNotifier;
66

77
use Illuminate\Notifications\Notification;
8+
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface;
89
use YieldStudio\LaravelExpoNotifier\Dto\ExpoMessage;
910
use YieldStudio\LaravelExpoNotifier\Exceptions\ExpoNotificationsException;
1011

1112
final class ExpoNotificationsChannel
1213
{
1314
public function __construct(
14-
protected readonly ExpoNotificationsService $expoNotificationsService,
15+
protected readonly ExpoNotificationsServiceInterface $expoNotificationsService,
1516
) {
1617
}
1718

src/ExpoNotificationsService.php

Lines changed: 110 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
namespace YieldStudio\LaravelExpoNotifier;
66

77
use Illuminate\Http\Client\PendingRequest;
8+
use Illuminate\Http\Client\Response;
89
use Illuminate\Support\Arr;
910
use Illuminate\Support\Collection;
1011
use Illuminate\Support\Facades\Http;
12+
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface;
1113
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface;
1214
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface;
1315
use YieldStudio\LaravelExpoNotifier\Dto\ExpoMessage;
@@ -17,10 +19,22 @@
1719
use YieldStudio\LaravelExpoNotifier\Events\InvalidExpoToken;
1820
use YieldStudio\LaravelExpoNotifier\Exceptions\ExpoNotificationsException;
1921

20-
final class ExpoNotificationsService
22+
final class ExpoNotificationsService implements ExpoNotificationsServiceInterface
2123
{
24+
public const PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT = 100;
25+
26+
public const SEND_NOTIFICATION_ENDPOINT = '/send';
27+
2228
private PendingRequest $http;
2329

30+
private ?Collection $expoMessages;
31+
32+
private ?Collection $notificationsToSend;
33+
34+
private ?Collection $notificationChunks;
35+
36+
private Collection $tickets;
37+
2438
public function __construct(
2539
string $apiUrl,
2640
string $host,
@@ -33,62 +47,22 @@ public function __construct(
3347
'accept-encoding' => 'gzip, deflate',
3448
'content-type' => 'application/json',
3549
])->baseUrl($apiUrl);
50+
51+
$this->tickets = collect();
3652
}
3753

3854
/**
3955
* @param ExpoMessage|ExpoMessage[]|Collection<int, ExpoMessage> $expoMessages
4056
* @return Collection<int, PushTicketResponse>
41-
*
42-
* @throws ExpoNotificationsException
4357
*/
4458
public function notify(ExpoMessage|Collection|array $expoMessages): Collection
4559
{
4660
/** @var Collection<int, ExpoMessage> $expoMessages */
47-
$expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages));
48-
49-
$shouldBatchFilter = fn (ExpoMessage $message) => $message->shouldBatch;
50-
51-
// Store notifications to send in the next batch
52-
$expoMessages
53-
->filter($shouldBatchFilter)
54-
->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message));
55-
56-
// Filter notifications to send now
57-
$toSend = $expoMessages
58-
->reject($shouldBatchFilter)
59-
->map(fn (ExpoMessage $message) => $message->toExpoData())
60-
->values();
61-
62-
if ($toSend->isEmpty()) {
63-
return collect();
64-
}
65-
66-
$response = $this->http->post('/send', $toSend->toArray());
67-
if (! $response->successful()) {
68-
throw new ExpoNotificationsException($response->toPsrResponse());
69-
}
70-
71-
$data = json_decode($response->body(), true);
72-
if (! empty($data['errors'])) {
73-
throw new ExpoNotificationsException($response->toPsrResponse());
74-
}
75-
76-
$tickets = collect($data['data'])->map(function ($responseItem) {
77-
if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) {
78-
return (new PushTicketResponse())
79-
->status($responseItem['status'])
80-
->message($responseItem['message'])
81-
->details($responseItem['details']);
82-
}
83-
84-
return (new PushTicketResponse())
85-
->status($responseItem['status'])
86-
->ticketId($responseItem['id']);
87-
});
61+
$this->expoMessages = $expoMessages instanceof Collection ? $expoMessages : collect(Arr::wrap($expoMessages));
8862

89-
$this->checkAndStoreTickets($toSend->pluck('to')->flatten(), $tickets);
90-
91-
return $tickets;
63+
return $this->storeNotificationsToSendInTheNextBatch()
64+
->prepareNotificationsToSendNow()
65+
->sendNotifications();
9266
}
9367

9468
/**
@@ -130,13 +104,17 @@ public function receipts(Collection|array $tokenIds): Collection
130104
});
131105
}
132106

107+
public function getNotificationChunks(): Collection
108+
{
109+
return $this->notificationChunks ?? collect();
110+
}
111+
133112
/**
134113
* @param Collection<int, string> $tokens
135-
* @param Collection<int, PushTicketResponse> $tickets
136114
*/
137-
private function checkAndStoreTickets(Collection $tokens, Collection $tickets): void
115+
private function checkAndStoreTickets(Collection $tokens): void
138116
{
139-
$tickets
117+
$this->tickets
140118
->intersectByKeys($tokens)
141119
->each(function (PushTicketResponse $ticket, $index) use ($tokens) {
142120
if ($ticket->status === ExpoResponseStatus::ERROR->value) {
@@ -152,4 +130,86 @@ private function checkAndStoreTickets(Collection $tokens, Collection $tickets):
152130
}
153131
});
154132
}
133+
134+
private function storeNotificationsToSendInTheNextBatch(): ExpoNotificationsService
135+
{
136+
$this->expoMessages
137+
->filter(fn (ExpoMessage $message) => $message->shouldBatch)
138+
->each(fn (ExpoMessage $message) => $this->notificationStorage->store($message));
139+
140+
return $this;
141+
}
142+
143+
private function prepareNotificationsToSendNow(): ExpoNotificationsService
144+
{
145+
$this->notificationsToSend = $this->expoMessages
146+
->reject(fn (ExpoMessage $message) => $message->shouldBatch)
147+
->map(fn (ExpoMessage $message) => $message->toExpoData())
148+
->values();
149+
150+
// Splits into multiples chunks of max limitation
151+
$this->notificationChunks = $this->notificationsToSend->chunk(self::PUSH_NOTIFICATIONS_PER_REQUEST_LIMIT);
152+
153+
return $this;
154+
}
155+
156+
private function sendNotifications(): Collection
157+
{
158+
if ($this->notificationsToSend->isEmpty()) {
159+
return collect();
160+
}
161+
162+
$this->notificationChunks
163+
->each(
164+
fn ($chunk, $index) => $this->sendNotificationsChunk($chunk->toArray())
165+
);
166+
167+
$this->checkAndStoreTickets($this->notificationsToSend->pluck('to')->flatten());
168+
169+
return $this->tickets;
170+
}
171+
172+
private function handleSendNotificationsResponse(Response $response): void
173+
{
174+
$data = json_decode($response->body(), true, 512, JSON_THROW_ON_ERROR);
175+
if (! empty($data['errors'])) {
176+
throw new ExpoNotificationsException($response->toPsrResponse());
177+
}
178+
179+
$this->setTicketsFromData($data);
180+
}
181+
182+
private function setTicketsFromData(array $data): ExpoNotificationsService
183+
{
184+
collect($data['data'])
185+
->each(function ($responseItem) {
186+
if ($responseItem['status'] === ExpoResponseStatus::ERROR->value) {
187+
$this->tickets->push(
188+
(new PushTicketResponse())
189+
->status($responseItem['status'])
190+
->message($responseItem['message'])
191+
->details($responseItem['details'])
192+
);
193+
} else {
194+
$this->tickets->push(
195+
(new PushTicketResponse())
196+
->status($responseItem['status'])
197+
->ticketId($responseItem['id'])
198+
);
199+
}
200+
});
201+
202+
return $this;
203+
}
204+
205+
private function sendNotificationsChunk(array $chunk)
206+
{
207+
$response = $this->http->post(self::SEND_NOTIFICATION_ENDPOINT, $chunk);
208+
209+
if (! $response->successful()) {
210+
throw new ExpoNotificationsException($response->toPsrResponse());
211+
}
212+
213+
$this->handleSendNotificationsResponse($response);
214+
}
155215
}

src/ExpoNotificationsServiceProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Support\ServiceProvider;
99
use YieldStudio\LaravelExpoNotifier\Commands\CheckTickets;
1010
use YieldStudio\LaravelExpoNotifier\Commands\SendPendingNotifications;
11+
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoNotificationsServiceInterface;
1112
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoPendingNotificationStorageInterface;
1213
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTicketStorageInterface;
1314
use YieldStudio\LaravelExpoNotifier\Contracts\ExpoTokenStorageInterface;
@@ -22,7 +23,7 @@ public function register(): void
2223
$this->app->bind(ExpoTicketStorageInterface::class, config('expo-notifications.drivers.ticket'));
2324
$this->app->bind(ExpoPendingNotificationStorageInterface::class, config('expo-notifications.drivers.notification'));
2425

25-
$this->app->bind(ExpoNotificationsService::class, function ($app) {
26+
$this->app->bind(ExpoNotificationsServiceInterface::class, function ($app) {
2627
$apiUrl = config('expo-notifications.service.api_url');
2728
$host = config('expo-notifications.service.host');
2829

0 commit comments

Comments
 (0)