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