Files
autocrm/app/Services/Notifications/WebPushService.php
T
Vasyka c413004930 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>
2026-05-28 05:11:18 +00:00

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];
}
}