diff --git a/.env.example b/.env.example index eec8e21..34a4a95 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,11 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@service.mir.md" MAIL_FROM_NAME="${APP_NAME}" +# Web Push (VAPID) — generate with: php artisan push:vapid +VAPID_SUBJECT=mailto:admin@service.mir.md +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + # Storage — local pentru MVP, S3-compatible mai târziu FILESYSTEM_DISK=local diff --git a/Dockerfile b/Dockerfile index 8a5321a..0e470a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,10 @@ RUN install-php-extensions \ opcache \ pcntl \ sockets \ - exif + exif \ + curl \ + mbstring \ + gmp # System tools RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/app/Console/Commands/GenerateVapidKeysCommand.php b/app/Console/Commands/GenerateVapidKeysCommand.php new file mode 100644 index 0000000..923d7d8 --- /dev/null +++ b/app/Console/Commands/GenerateVapidKeysCommand.php @@ -0,0 +1,28 @@ +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; + } +} diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index b1cf73b..d1b21ff 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -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') diff --git a/app/Http/Controllers/PushSubscriptionController.php b/app/Http/Controllers/PushSubscriptionController.php new file mode 100644 index 0000000..86ae71e --- /dev/null +++ b/app/Http/Controllers/PushSubscriptionController.php @@ -0,0 +1,44 @@ +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]); + } +} diff --git a/app/Models/Tenant/PushSubscription.php b/app/Models/Tenant/PushSubscription.php new file mode 100644 index 0000000..600be4b --- /dev/null +++ b/app/Models/Tenant/PushSubscription.php @@ -0,0 +1,22 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 3d842db..0b5b185 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -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')) { diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 23778f8..7f6b36a 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -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 + @if ($broadcastEnabled && $tenant) diff --git a/app/Services/Notifications/WebPushService.php b/app/Services/Notifications/WebPushService.php new file mode 100644 index 0000000..a206819 --- /dev/null +++ b/app/Services/Notifications/WebPushService.php @@ -0,0 +1,116 @@ +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 $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]; + } +} diff --git a/composer.json b/composer.json index dd0b12e..7883304 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/reverb": "^1.10", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", + "minishlink/web-push": "^10.0", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-medialibrary": "^11.22", "spatie/laravel-permission": "^7.4", diff --git a/composer.lock b/composer.lock index 8e0aa6f..de4fa5b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7ab8969350ac00500802df521319890", + "content-hash": "72e35bc95dd2b8489e5a7b77b421d237", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -4277,6 +4277,76 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "name": "minishlink/web-push", + "version": "v10.0.3", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "547695eb42b062517fc604c85d6f7bb8174d31b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/547695eb42b062517fc604c85d6f7bb8174d31b0", + "reference": "547695eb42b062517fc604c85d6f7bb8174d31b0", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.2", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.0.3" + }, + "time": "2026-03-09T23:16:02+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -7553,6 +7623,181 @@ ], "time": "2026-01-12T07:42:22+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, { "name": "stancl/jobpipeline", "version": "v1.9.0", @@ -10801,6 +11046,95 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.6", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/e8ab00927a3856f3f0c8218226382cd6a58928a1", + "reference": "e8ab00927a3856f3f0c8218226382cd6a58928a1", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.6" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-04-14T07:44:20+00:00" } ], "packages-dev": [ diff --git a/config/webpush.php b/config/webpush.php new file mode 100644 index 0000000..330c5e6 --- /dev/null +++ b/config/webpush.php @@ -0,0 +1,17 @@ + [ + 'subject' => env('VAPID_SUBJECT', 'mailto:admin@service.mir.md'), + 'public_key' => env('VAPID_PUBLIC_KEY'), + 'private_key' => env('VAPID_PRIVATE_KEY'), + ], + + // TTL (seconds) the push service keeps the message if device is offline. + 'ttl' => env('VAPID_TTL', 2419200), // 4 weeks +]; diff --git a/database/migrations/2026_05_28_090000_create_push_subscriptions.php b/database/migrations/2026_05_28_090000_create_push_subscriptions.php new file mode 100644 index 0000000..6979999 --- /dev/null +++ b/database/migrations/2026_05_28_090000_create_push_subscriptions.php @@ -0,0 +1,31 @@ +id(); + $t->foreignId('company_id')->nullable()->constrained()->cascadeOnDelete(); + $t->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $t->string('endpoint', 500); + $t->string('public_key')->nullable(); // p256dh + $t->string('auth_token')->nullable(); // auth + $t->string('content_encoding', 32)->default('aesgcm'); + $t->string('user_agent')->nullable(); + $t->timestamps(); + + $t->unique('endpoint', 'push_subscriptions_endpoint_unique'); + $t->index(['company_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('push_subscriptions'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 10790e1..861e9cb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,5 +33,8 @@ + + + diff --git a/routes/web.php b/routes/web.php index 9fdf494..1fae449 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); }); diff --git a/tests/Feature/PushSubscriptionTest.php b/tests/Feature/PushSubscriptionTest.php new file mode 100644 index 0000000..e29999f --- /dev/null +++ b/tests/Feature/PushSubscriptionTest.php @@ -0,0 +1,112 @@ +makeUser('sub1'); + + $this->actingAs($user) + ->postJson('http://sub1.service.mir.md/push/subscribe', [ + 'endpoint' => 'https://push.example.com/abc123', + 'keys' => ['p256dh' => 'PUBLICKEY', 'auth' => 'AUTHTOKEN'], + 'contentEncoding' => 'aes128gcm', + ]) + ->assertOk() + ->assertJson(['ok' => true]); + + $this->assertDatabaseHas('push_subscriptions', [ + 'endpoint' => 'https://push.example.com/abc123', + 'user_id' => $user->id, + 'public_key' => 'PUBLICKEY', + 'content_encoding' => 'aes128gcm', + ]); + } + + public function test_subscribe_upserts_on_duplicate_endpoint(): void + { + [$company, $user] = $this->makeUser('sub2'); + + $payload = [ + 'endpoint' => 'https://push.example.com/dup', + 'keys' => ['p256dh' => 'KEY1', 'auth' => 'AUTH1'], + ]; + + $this->actingAs($user)->postJson('http://sub2.service.mir.md/push/subscribe', $payload)->assertOk(); + $payload['keys']['p256dh'] = 'KEY2'; + $this->actingAs($user)->postJson('http://sub2.service.mir.md/push/subscribe', $payload)->assertOk(); + + $this->assertEquals(1, PushSubscription::where('endpoint', 'https://push.example.com/dup')->count()); + $this->assertEquals('KEY2', PushSubscription::where('endpoint', 'https://push.example.com/dup')->value('public_key')); + } + + public function test_subscribe_requires_auth(): void + { + $this->makeUser('sub3'); + $this->postJson('http://sub3.service.mir.md/push/subscribe', [ + 'endpoint' => 'https://push.example.com/noauth', + 'keys' => ['p256dh' => 'X', 'auth' => 'Y'], + ])->assertStatus(401); + } + + public function test_validation_rejects_missing_keys(): void + { + [$company, $user] = $this->makeUser('sub4'); + $this->actingAs($user) + ->postJson('http://sub4.service.mir.md/push/subscribe', [ + 'endpoint' => 'https://push.example.com/x', + ]) + ->assertStatus(422); + } + + public function test_webpush_service_reports_configured(): void + { + $svc = app(WebPushService::class); + $this->assertTrue($svc->configured()); + $this->assertNotEmpty($svc->publicKey()); + } + + public function test_send_to_user_with_no_subscriptions_returns_zero(): void + { + [$company, $user] = $this->makeUser('sub5'); + $r = app(WebPushService::class)->sendToUser($user->id, 'T', 'B'); + $this->assertEquals(0, $r['sent']); + $this->assertEquals(0, $r['pruned']); + } + + private function makeUser(string $slug): array + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, + 'slug' => $slug, + 'name' => ucfirst($slug), + 'status' => 'active', + ]); + app(TenantManager::class)->setCurrent($company); + + $user = User::create([ + 'company_id' => $company->id, + 'name' => 'Push User', + 'email' => $slug . '@example.com', + 'password' => bcrypt('secret'), + 'role' => 'admin', + 'status' => 'active', + ]); + + return [$company, $user]; + } +}