c413004930
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>
117 lines
3.8 KiB
PHP
117 lines
3.8 KiB
PHP
<?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];
|
|
}
|
|
}
|