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:
@@ -58,6 +58,11 @@ MAIL_ENCRYPTION=null
|
|||||||
MAIL_FROM_ADDRESS="noreply@service.mir.md"
|
MAIL_FROM_ADDRESS="noreply@service.mir.md"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
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
|
# Storage — local pentru MVP, S3-compatible mai târziu
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -41,7 +41,10 @@ RUN install-php-extensions \
|
|||||||
opcache \
|
opcache \
|
||||||
pcntl \
|
pcntl \
|
||||||
sockets \
|
sockets \
|
||||||
exif
|
exif \
|
||||||
|
curl \
|
||||||
|
mbstring \
|
||||||
|
gmp
|
||||||
|
|
||||||
# System tools
|
# System tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,6 +245,31 @@ class Settings extends Page
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
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')
|
Actions\Action::make('telegram_test')
|
||||||
->label('Testează bot Telegram')
|
->label('Testează bot Telegram')
|
||||||
->icon('heroicon-m-bolt')
|
->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,21 @@ class WorkOrder extends Model implements HasMedia
|
|||||||
app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo);
|
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;
|
// Warehouse lifecycle: status=done → consume reservations into issues;
|
||||||
// status=cancelled → release reservations.
|
// status=cancelled → release reservations.
|
||||||
if ($wo->wasChanged('status')) {
|
if ($wo->wasChanged('status')) {
|
||||||
|
|||||||
@@ -222,13 +222,63 @@ class TenantPanelProvider extends PanelProvider
|
|||||||
$reverbPort = config('broadcasting.connections.reverb.options.port');
|
$reverbPort = config('broadcasting.connections.reverb.options.port');
|
||||||
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
|
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
|
||||||
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
|
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
|
||||||
|
$vapidPublic = config('webpush.vapid.public_key');
|
||||||
|
$csrf = csrf_token();
|
||||||
@endphp
|
@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>
|
<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) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', async () => {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
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>
|
</script>
|
||||||
@if ($broadcastEnabled && $tenant)
|
@if ($broadcastEnabled && $tenant)
|
||||||
<script src="https://js.pusher.com/8.4/pusher.min.js"></script>
|
<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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"laravel/reverb": "^1.10",
|
"laravel/reverb": "^1.10",
|
||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"minishlink/web-push": "^10.0",
|
||||||
"spatie/laravel-activitylog": "^5.0",
|
"spatie/laravel-activitylog": "^5.0",
|
||||||
"spatie/laravel-medialibrary": "^11.22",
|
"spatie/laravel-medialibrary": "^11.22",
|
||||||
"spatie/laravel-permission": "^7.4",
|
"spatie/laravel-permission": "^7.4",
|
||||||
|
|||||||
Generated
+335
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d7ab8969350ac00500802df521319890",
|
"content-hash": "72e35bc95dd2b8489e5a7b77b421d237",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "barryvdh/laravel-dompdf",
|
"name": "barryvdh/laravel-dompdf",
|
||||||
@@ -4277,6 +4277,76 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-07-25T09:04:22+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -7553,6 +7623,181 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-12T07:42:22+00:00"
|
"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",
|
"name": "stancl/jobpipeline",
|
||||||
"version": "v1.9.0",
|
"version": "v1.9.0",
|
||||||
@@ -10801,6 +11046,95 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-04-26T05:33:54+00:00"
|
"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": [
|
"packages-dev": [
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
| VAPID keys identify the application server to push services.
|
||||||
|
| Generate with: php artisan push:vapid
|
||||||
|
| Then add the printed lines to .env.
|
||||||
|
*/
|
||||||
|
'vapid' => [
|
||||||
|
'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
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('push_subscriptions', function (Blueprint $t) {
|
||||||
|
$t->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -33,5 +33,8 @@
|
|||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
<env name="CENTRAL_DOMAIN" value="service.mir.md"/>
|
<env name="CENTRAL_DOMAIN" value="service.mir.md"/>
|
||||||
|
<env name="VAPID_SUBJECT" value="mailto:test@service.mir.md"/>
|
||||||
|
<env name="VAPID_PUBLIC_KEY" value="BKWzp08jgvPShDAn9ND2RZZUviZi8OzSiQqEZGyxKbHwpRXRgDsqNYIk-iQzAOdMHYWjt9tS_9f-Nud5xMBu0YA"/>
|
||||||
|
<env name="VAPID_PRIVATE_KEY" value="F4FtgCigYXyi4C1fEa4a5_D2WUQM2ndwyByL-0FKqQg"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
+30
-4
@@ -57,10 +57,14 @@ Route::get('/login', function (Request $request) {
|
|||||||
return redirect($tenant ? '/app/login' : '/admin/login');
|
return redirect($tenant ? '/app/login' : '/admin/login');
|
||||||
})->name('login');
|
})->name('login');
|
||||||
|
|
||||||
// ─── Print sheets (auth required, tenant-scoped) ───────────────────
|
// ─── Print sheets + push subscriptions (auth, tenant-scoped) ───────
|
||||||
Route::middleware(['web', 'auth'])->group(function () {
|
Route::middleware(['web', 'auth'])->group(function () {
|
||||||
Route::get('/parts/labels', [\App\Http\Controllers\PartLabelsController::class, 'sheet'])
|
Route::get('/parts/labels', [\App\Http\Controllers\PartLabelsController::class, 'sheet'])
|
||||||
->name('parts.labels');
|
->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) ──────────────
|
// ─── 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');
|
])->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 () {
|
Route::get('/sw.js', function () {
|
||||||
return response(<<<'JS'
|
return response(<<<'JS'
|
||||||
const CACHE = 'autocrm-shell-v1';
|
const CACHE = 'autocrm-shell-v2';
|
||||||
const SHELL = ['/manifest.json'];
|
const SHELL = ['/manifest.json'];
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
|
||||||
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
self.addEventListener('activate', e => {
|
self.addEventListener('activate', e => {
|
||||||
e.waitUntil(caches.keys().then(keys =>
|
e.waitUntil(caches.keys().then(keys =>
|
||||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||||
));
|
));
|
||||||
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
self.addEventListener('fetch', e => {
|
self.addEventListener('fetch', e => {
|
||||||
const u = new URL(e.request.url);
|
const u = new URL(e.request.url);
|
||||||
if (e.request.method !== 'GET') return;
|
if (e.request.method !== 'GET') return;
|
||||||
// network-first for app routes; cache-first for static
|
|
||||||
if (u.pathname.startsWith('/build/') || u.pathname.startsWith('/pwa/')) {
|
if (u.pathname.startsWith('/build/') || u.pathname.startsWith('/pwa/')) {
|
||||||
e.respondWith(caches.match(e.request).then(m => m || fetch(e.request).then(r => {
|
e.respondWith(caches.match(e.request).then(m => m || fetch(e.request).then(r => {
|
||||||
const copy = r.clone();
|
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']);
|
JS, 200, ['Content-Type' => 'application/javascript', 'Cache-Control' => 'public, max-age=3600']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\PushSubscription;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
use App\Services\Notifications\WebPushService;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PushSubscriptionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_subscribe_stores_subscription_for_user(): void
|
||||||
|
{
|
||||||
|
[$company, $user] = $this->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];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user