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
+30 -4
View File
@@ -57,10 +57,14 @@ Route::get('/login', function (Request $request) {
return redirect($tenant ? '/app/login' : '/admin/login');
})->name('login');
// ─── Print sheets (auth required, tenant-scoped) ───────────────────
// ─── Print sheets + push subscriptions (auth, tenant-scoped) ───────
Route::middleware(['web', 'auth'])->group(function () {
Route::get('/parts/labels', [\App\Http\Controllers\PartLabelsController::class, 'sheet'])
->name('parts.labels');
Route::post('/push/subscribe', [\App\Http\Controllers\PushSubscriptionController::class, 'subscribe'])
->name('push.subscribe');
Route::post('/push/unsubscribe', [\App\Http\Controllers\PushSubscriptionController::class, 'unsubscribe'])
->name('push.unsubscribe');
});
// ─── Telegram webhook (per-tenant, on central domain) ──────────────
@@ -231,23 +235,24 @@ Route::get('/manifest.json', function (Request $request) {
])->header('Cache-Control', 'public, max-age=3600');
});
// Service worker stub — minimal cache for shell.
// Service worker — shell cache + Web Push handlers.
Route::get('/sw.js', function () {
return response(<<<'JS'
const CACHE = 'autocrm-shell-v1';
const CACHE = 'autocrm-shell-v2';
const SHELL = ['/manifest.json'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
));
self.clients.claim();
});
self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method !== 'GET') return;
// network-first for app routes; cache-first for static
if (u.pathname.startsWith('/build/') || u.pathname.startsWith('/pwa/')) {
e.respondWith(caches.match(e.request).then(m => m || fetch(e.request).then(r => {
const copy = r.clone();
@@ -256,5 +261,26 @@ Route::get('/sw.js', function () {
})));
}
});
// Web Push: show notification from server payload.
self.addEventListener('push', e => {
let data = { title: 'AutoCRM', body: '', url: '/app', tag: 'autocrm' };
try { if (e.data) data = Object.assign(data, e.data.json()); } catch (err) {}
e.waitUntil(self.registration.showNotification(data.title, {
body: data.body,
tag: data.tag,
data: { url: data.url },
icon: '/pwa/icon-192.png',
badge: '/pwa/icon-192.png',
}));
});
// Focus or open the target URL on click.
self.addEventListener('notificationclick', e => {
e.notification.close();
const url = (e.notification.data && e.notification.data.url) || '/app';
e.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
for (const c of list) { if ('focus' in c) { c.navigate(url); return c.focus(); } }
if (clients.openWindow) return clients.openWindow(url);
}));
});
JS, 200, ['Content-Type' => 'application/javascript', 'Cache-Control' => 'public, max-age=3600']);
});