Stage 15 — PWA complete: install prompt + Web Push notifications
Dependency: - minishlink/web-push v10 (VAPID JWT + aes128gcm payload encryption) - Dockerfile: add curl, mbstring, gmp extensions (web-push needs ext-curl) VAPID: - config/webpush.php from env; `php artisan push:vapid` generates keypair - Shared platform keypair; .env.example has empty placeholders Schema: - push_subscriptions (user/company, endpoint unique, p256dh, auth, encoding) WebPushService: - send / sendToUser / dispatch via WebPush::flush - Auto-prunes subscriptions reported expired (404/410) Subscribe flow: - POST /push/subscribe + /push/unsubscribe (auth, tenant) - Tenant panel JS subscribes after SW registration with VAPID public key Service worker (/sw.js): - Cache v2, push listener → showNotification, notificationclick → focus/open Install prompt: - Floating "Instalează aplicația" button wired to beforeinstallprompt Staff push: - WorkOrder master_id change → push to assigned mechanic - Settings "Test notificare push" action Tests (6 new): - subscribe stores + upserts; requires auth (401); validation (422); service configured; sendToUser with no subs returns zero Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\Tenant\PushSubscription;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Minishlink\WebPush\Subscription as PushSub;
|
||||
use Minishlink\WebPush\WebPush;
|
||||
|
||||
/**
|
||||
* Sends Web Push notifications via VAPID. Subscriptions that the push service
|
||||
* reports as gone (404/410) are pruned automatically.
|
||||
*/
|
||||
class WebPushService
|
||||
{
|
||||
public function configured(): bool
|
||||
{
|
||||
return ! empty(config('webpush.vapid.public_key'))
|
||||
&& ! empty(config('webpush.vapid.private_key'));
|
||||
}
|
||||
|
||||
public function publicKey(): ?string
|
||||
{
|
||||
return config('webpush.vapid.public_key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to a single stored subscription. Returns true on accepted delivery.
|
||||
*/
|
||||
public function send(PushSubscription $sub, string $title, string $body, ?string $url = null, ?string $tag = null): bool
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
Log::debug('webpush: VAPID not configured, skip');
|
||||
return false;
|
||||
}
|
||||
|
||||
$results = $this->dispatch([$sub], $title, $body, $url, $tag);
|
||||
return $results['sent'] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to every subscription of a user (a person may have several devices).
|
||||
*
|
||||
* @return array{sent:int, pruned:int}
|
||||
*/
|
||||
public function sendToUser(int $userId, string $title, string $body, ?string $url = null, ?string $tag = null): array
|
||||
{
|
||||
$subs = PushSubscription::where('user_id', $userId)->get();
|
||||
return $this->dispatch($subs, $title, $body, $url, $tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<PushSubscription> $subs
|
||||
* @return array{sent:int, pruned:int}
|
||||
*/
|
||||
public function dispatch(iterable $subs, string $title, string $body, ?string $url = null, ?string $tag = null): array
|
||||
{
|
||||
if (! $this->configured()) return ['sent' => 0, 'pruned' => 0];
|
||||
|
||||
$webPush = new WebPush([
|
||||
'VAPID' => [
|
||||
'subject' => config('webpush.vapid.subject'),
|
||||
'publicKey' => config('webpush.vapid.public_key'),
|
||||
'privateKey' => config('webpush.vapid.private_key'),
|
||||
],
|
||||
]);
|
||||
$webPush->setDefaultOptions(['TTL' => (int) config('webpush.ttl', 2419200)]);
|
||||
|
||||
$payload = json_encode([
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'url' => $url ?? '/app',
|
||||
'tag' => $tag ?? 'autocrm',
|
||||
]);
|
||||
|
||||
// Index by endpoint so we can prune by the report's endpoint.
|
||||
$byEndpoint = [];
|
||||
foreach ($subs as $s) {
|
||||
$byEndpoint[$s->endpoint] = $s;
|
||||
$webPush->queueNotification(
|
||||
PushSub::create([
|
||||
'endpoint' => $s->endpoint,
|
||||
'publicKey' => $s->public_key,
|
||||
'authToken' => $s->auth_token,
|
||||
'contentEncoding' => $s->content_encoding ?: 'aesgcm',
|
||||
]),
|
||||
$payload
|
||||
);
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$pruned = 0;
|
||||
foreach ($webPush->flush() as $report) {
|
||||
$endpoint = $report->getRequest()->getUri()->__toString();
|
||||
if ($report->isSuccess()) {
|
||||
$sent++;
|
||||
} elseif ($report->isSubscriptionExpired()) {
|
||||
// 404/410 — device unsubscribed; remove stored row.
|
||||
if (isset($byEndpoint[$endpoint])) {
|
||||
$byEndpoint[$endpoint]->delete();
|
||||
$pruned++;
|
||||
} else {
|
||||
PushSubscription::where('endpoint', $endpoint)->delete();
|
||||
$pruned++;
|
||||
}
|
||||
} else {
|
||||
Log::warning('webpush: delivery failed', [
|
||||
'endpoint' => $endpoint,
|
||||
'reason' => $report->getReason(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return ['sent' => $sent, 'pruned' => $pruned];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user