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:
+30
-4
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user