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:
2026-05-28 05:11:18 +00:00
parent e48ef1b755
commit c413004930
16 changed files with 840 additions and 8 deletions
@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Minishlink\WebPush\VAPID;
class GenerateVapidKeysCommand extends Command
{
protected $signature = 'push:vapid';
protected $description = 'Generate a VAPID keypair for Web Push and print the .env lines.';
public function handle(): int
{
$keys = VAPID::createVapidKeys();
$this->info('VAPID keys generated. Add these to your .env:');
$this->newLine();
$this->line('VAPID_SUBJECT=mailto:admin@service.mir.md');
$this->line('VAPID_PUBLIC_KEY=' . $keys['publicKey']);
$this->line('VAPID_PRIVATE_KEY=' . $keys['privateKey']);
$this->newLine();
$this->warn('Keep the private key secret. Re-generating invalidates existing subscriptions.');
return self::SUCCESS;
}
}
+25
View File
@@ -245,6 +245,31 @@ class Settings extends Page
protected function getHeaderActions(): array
{
return [
Actions\Action::make('push_test')
->label('Test notificare push')
->icon('heroicon-m-bell-alert')
->color('gray')
->action(function () {
$svc = app(\App\Services\Notifications\WebPushService::class);
if (! $svc->configured()) {
Notification::make()
->title('Web Push neconfigurat')
->body('Rulează `php artisan push:vapid` și adaugă cheile în .env.')
->warning()->send();
return;
}
$r = $svc->sendToUser(
(int) auth()->id(),
'Test AutoCRM',
'Notificările push funcționează ✅',
'/app',
);
Notification::make()
->title($r['sent'] > 0 ? "Trimis pe {$r['sent']} dispozitiv(e)" : 'Niciun dispozitiv abonat')
->body($r['sent'] > 0 ? null : 'Deschide panoul pe telefon și acceptă notificările întâi.')
->{$r['sent'] > 0 ? 'success' : 'warning'}()
->send();
}),
Actions\Action::make('telegram_test')
->label('Testează bot Telegram')
->icon('heroicon-m-bolt')
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant\PushSubscription;
use Illuminate\Http\Request;
class PushSubscriptionController extends Controller
{
public function subscribe(Request $request)
{
$data = $request->validate([
'endpoint' => 'required|string|max:500',
'keys.p256dh' => 'required|string',
'keys.auth' => 'required|string',
'contentEncoding' => 'nullable|string|max:32',
]);
$user = $request->user();
PushSubscription::updateOrCreate(
['endpoint' => $data['endpoint']],
[
'company_id' => $user?->company_id,
'user_id' => $user?->id,
'public_key' => $data['keys']['p256dh'],
'auth_token' => $data['keys']['auth'],
'content_encoding' => $data['contentEncoding'] ?? 'aesgcm',
'user_agent' => substr((string) $request->userAgent(), 0, 255),
]
);
return response()->json(['ok' => true]);
}
public function unsubscribe(Request $request)
{
$endpoint = $request->input('endpoint');
if ($endpoint) {
PushSubscription::where('endpoint', $endpoint)->delete();
}
return response()->json(['ok' => true]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushSubscription extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'user_id', 'endpoint',
'public_key', 'auth_token', 'content_encoding', 'user_agent',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+15
View File
@@ -141,6 +141,21 @@ class WorkOrder extends Model implements HasMedia
app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo);
}
// Push the assigned mechanic when a WO gets assigned to them.
if ($wo->wasChanged('master_id') && $wo->master_id) {
try {
app(\App\Services\Notifications\WebPushService::class)->sendToUser(
(int) $wo->master_id,
'Fișă nouă atribuită',
"Fișa #{$wo->number} · " . ($wo->vehicle?->plate ?? ''),
'/app/resources/work-orders/' . $wo->id . '/edit',
'wo-assign-' . $wo->id,
);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::debug('WO assign push skipped: ' . $e->getMessage());
}
}
// Warehouse lifecycle: status=done → consume reservations into issues;
// status=cancelled → release reservations.
if ($wo->wasChanged('status')) {
+52 -2
View File
@@ -222,13 +222,63 @@ class TenantPanelProvider extends PanelProvider
$reverbPort = config('broadcasting.connections.reverb.options.port');
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
$vapidPublic = config('webpush.vapid.public_key');
$csrf = csrf_token();
@endphp
<button id="autocrm-install" type="button" style="display:none;position:fixed;bottom:16px;right:16px;z-index:60;background:#3b82f6;color:#fff;border:0;border-radius:24px;padding:10px 18px;font-size:13px;font-weight:600;box-shadow:0 4px 12px rgba(0,0,0,.2);cursor:pointer;">
Instalează aplicația
</button>
<script>
// Service worker + Web Push subscription.
const AUTOCRM_VAPID = @json($vapidPublic);
function urlBase64ToUint8Array(b64) {
const pad = '='.repeat((4 - b64.length % 4) % 4);
const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js');
if (AUTOCRM_VAPID && 'PushManager' in window) {
const perm = await Notification.requestPermission().catch(() => 'default');
if (perm === 'granted') {
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(AUTOCRM_VAPID),
});
}
await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ $csrf }}' },
body: JSON.stringify(sub.toJSON()),
});
}
}
} catch (e) { /* push optional */ }
});
}
// PWA install prompt.
let deferredPrompt = null;
const installBtn = document.getElementById('autocrm-install');
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
if (installBtn) installBtn.style.display = 'block';
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
installBtn.style.display = 'none';
});
}
window.addEventListener('appinstalled', () => { if (installBtn) installBtn.style.display = 'none'; });
</script>
@if ($broadcastEnabled && $tenant)
<script src="https://js.pusher.com/8.4/pusher.min.js"></script>
@@ -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];
}
}