# Push Notifications Web — Laravel + Vue PWA

Documentation basée sur l'implémentation du projet rachid-shop.

---

## Principe général

```
Navigateur                        Laravel API
    │                                  │
    │── 1. Demande permission ─────────│
    │── 2. Crée subscription ──────────│
    │── 3. POST /push/subscribe ───────▶ (sauvegardé en base)
    │                                  │
    │                                  │ (événement métier : commande, promo…)
    │◀── 4. Push serveur ──────────────│ (via minishlink/web-push + VAPID)
    │
    └── Service Worker intercepte → affiche la notification
```

---

## Côté Laravel (API)

### 1. Installer le package

```bash
composer require minishlink/web-push
```

---

### 2. Générer les clés VAPID

```bash
php -r "
  \$keys = \Minishlink\WebPush\VAPID::createVapidKeys();
  echo 'Public: '  . \$keys['publicKey']  . PHP_EOL;
  echo 'Private: ' . \$keys['privateKey'] . PHP_EOL;
"
```

Ajouter dans `.env` :

```env
VAPID_PUBLIC_KEY=Bxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VAPID_PRIVATE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VAPID_SUBJECT=mailto:contact@monprojet.com
```

> Les clés VAPID sont générées **une seule fois** et ne doivent pas changer en production.

---

### 3. Migration — table des subscriptions

```php
Schema::create('push_subscriptions', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('shop_id')->nullable();      // si multi-tenant
    $table->nullableMorphs('notifiable');       // lien vers User ou Contact
    $table->string('endpoint');
    $table->text('public_key')->nullable();
    $table->text('auth_token')->nullable();
    $table->string('content_encoding')->default('aesgcm');
    $table->timestamps();
});
```

---

### 4. Modèle PushSubscription

```php
// app/Models/PushSubscription.php

use Illuminate\Support\Str;

class PushSubscription extends Model
{
    protected $guarded    = [];
    protected $keyType    = 'string';
    public    $incrementing = false;

    protected static function boot()
    {
        parent::boot();
        static::creating(fn($m) => $m->id = (string) Str::uuid());
    }

    public function notifiable()
    {
        return $this->morphTo();
    }
}
```

Relation inverse dans le modèle `User` :

```php
public function pushSubscriptions()
{
    return $this->morphMany(PushSubscription::class, 'notifiable');
}
```

---

### 5. Routes

```php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('vapid-public-key',      fn() => response(env('VAPID_PUBLIC_KEY'), 200));
    Route::post('push/subscribe',       [PushController::class, 'save']);
    Route::delete('push/unsubscribe',   [PushController::class, 'delete']);
});
```

---

### 6. Contrôleur

```php
// app/Http/Controllers/PushController.php

class PushController extends Controller
{
    // Sauvegarder la subscription envoyée par le navigateur
    public function save(Request $request)
    {
        $user = auth()->user();

        $user->pushSubscriptions()->updateOrCreate(
            ['endpoint' => $request->endpoint],
            [
                'endpoint'         => $request->endpoint,
                'public_key'       => $request->input('keys.p256dh'),
                'auth_token'       => $request->input('keys.auth'),
                'content_encoding' => $request->input('contentEncoding', 'aesgcm'),
            ]
        );

        return response()->json(['ok' => true], 201);
    }

    // Supprimer au logout ou désinscription
    public function delete(Request $request)
    {
        PushSubscription::where('endpoint', $request->endpoint)->delete();
        return response()->json(['ok' => true]);
    }
}
```

---

### 7. Service — envoyer une notification

```php
// app/Services/PushService.php

use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

class PushService
{
    private function webPush(): WebPush
    {
        return new WebPush([
            'VAPID' => [
                'subject'    => env('VAPID_SUBJECT'),
                'publicKey'  => env('VAPID_PUBLIC_KEY'),
                'privateKey' => env('VAPID_PRIVATE_KEY'),
            ],
        ]);
    }

    // Envoyer à un utilisateur précis (tous ses appareils)
    public function sendToUser(string $userId, string $title, string $body, string $url = '/'): void
    {
        $subscriptions = PushSubscription::where('notifiable_id', $userId)->get();
        $this->dispatch($subscriptions, compact('title', 'body', 'url'));
    }

    // Envoyer à tous les utilisateurs d'un shop
    public function sendToShop(string $shopId, string $title, string $body, string $url = '/'): void
    {
        $subscriptions = PushSubscription::where('shop_id', $shopId)->get();
        $this->dispatch($subscriptions, compact('title', 'body', 'url'));
    }

    private function dispatch($subscriptions, array $payload): void
    {
        $webPush = $this->webPush();
        $json    = json_encode($payload);
        $expired = [];

        foreach ($subscriptions as $sub) {
            $webPush->queueNotification(
                Subscription::create([
                    'endpoint'        => $sub->endpoint,
                    'publicKey'       => $sub->public_key,
                    'authToken'       => $sub->auth_token,
                    'contentEncoding' => $sub->content_encoding,
                ]),
                $json
            );
        }

        foreach ($webPush->flush() as $report) {
            if ($report->isSubscriptionExpired()) {
                $expired[] = $report->getRequest()->getUri()->__toString();
            }
        }

        // Nettoyer automatiquement les subscriptions expirées/invalides
        if ($expired) {
            PushSubscription::whereIn('endpoint', $expired)->delete();
        }
    }
}
```

---

### 8. Déclencher depuis un événement métier

**Injection directe dans un contrôleur :**

```php
class OrderController extends Controller
{
    public function store(Request $request, PushService $push)
    {
        $order = Order::create($request->validated());

        $push->sendToShop(
            shopId: $order->shop_id,
            title:  'Nouvelle commande #' . $order->number,
            body:   'Montant : ' . $order->total . ' FCFA',
            url:    '/admin/orders/' . $order->id
        );

        return response()->json($order, 201);
    }
}
```

**Via un Job asynchrone (recommandé pour ne pas bloquer la requête) :**

```php
// app/Jobs/SendPushJob.php
class SendPushJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private string $shopId,
        private array  $payload
    ) {}

    public function handle(PushService $push): void
    {
        $push->sendToShop(
            shopId: $this->shopId,
            title:  $this->payload['title'],
            body:   $this->payload['body'],
            url:    $this->payload['url'],
        );
    }
}

// Utilisation :
SendPushJob::dispatch($order->shop_id, [
    'title' => 'Nouvelle commande',
    'body'  => 'Commande #' . $order->number,
    'url'   => '/admin/orders/' . $order->id,
]);
```

**Envoyer la notification au client (Contact) lors d'un changement de statut :**

```php
// Dans OrdersController::updateCheckoutStatus
$push->sendToUser(
    userId: $checkout->contact_id,
    title:  'Commande mise à jour',
    body:   'Votre commande est : ' . $newStatus,
    url:    '/mes-commandes/' . $checkout->id
);
```

---

## Côté Vue.js (Frontend PWA)

### 1. Service Worker — intercepter le push

Dans le fichier service worker (ex: `src/service-worker.js`) :

```js
// Afficher la notification reçue du serveur
self.addEventListener('push', (event) => {
    const data = event.data?.json()
    if (!data) return

    event.waitUntil(
        self.registration.showNotification(data.title, {
            body:  data.body,
            icon:  data.icon || '/icons/icon-192.png',
            data:  { url: data.url || '/' },
        })
    )
})

// Naviguer vers l'URL au clic sur la notification
self.addEventListener('notificationclick', (event) => {
    event.notification.close()
    event.waitUntil(
        clients.openWindow(event.notification.data.url)
    )
})
```

---

### 2. S'abonner après connexion

```js
// src/services/registerPush.js

const API_URL = process.env.VUE_APP_API_URL  // ou import.meta.env.VITE_API_URL

export async function registerPush(authToken) {
    // Vérifier le support navigateur
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
        console.warn('Push non supporté sur ce navigateur')
        return
    }

    const reg = await navigator.serviceWorker.ready

    // Demander la permission à l'utilisateur
    if (Notification.permission !== 'granted') {
        const perm = await Notification.requestPermission()
        if (perm !== 'granted') {
            console.warn('Permission refusée')
            return
        }
    }

    // Récupérer la clé publique VAPID depuis l'API
    const res = await fetch(`${API_URL}/vapid-public-key`, {
        headers: { Authorization: `Bearer ${authToken}` }
    })
    const vapidKey = await res.text()

    // Créer ou récupérer la subscription navigateur
    let subscription = await reg.pushManager.getSubscription()
    if (!subscription) {
        subscription = await reg.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: urlBase64ToUint8Array(vapidKey),
        })
    }

    // Enregistrer la subscription côté API
    await fetch(`${API_URL}/push/subscribe`, {
        method:  'POST',
        headers: {
            'Content-Type': 'application/json',
            Authorization:  `Bearer ${authToken}`,
        },
        body: JSON.stringify({
            endpoint: subscription.endpoint,
            keys: {
                p256dh: subscription.toJSON().keys.p256dh,
                auth:   subscription.toJSON().keys.auth,
            },
            contentEncoding: subscription.toJSON().contentEncoding ?? 'aesgcm',
        }),
    })
}

// Convertit la clé VAPID base64 en Uint8Array (obligatoire)
function urlBase64ToUint8Array(base64) {
    const padding = '='.repeat((4 - base64.length % 4) % 4)
    const b64     = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
    const raw     = atob(b64)
    return Uint8Array.from([...raw].map(c => c.charCodeAt(0)))
}
```

---

### 3. Se désabonner au logout

```js
// src/services/unregisterPush.js

export async function unregisterPush(authToken) {
    if (!('serviceWorker' in navigator)) return

    const reg = await navigator.serviceWorker.ready
    const sub = await reg.pushManager.getSubscription()
    if (!sub) return

    // Supprimer côté API
    await fetch(`${API_URL}/push/unsubscribe`, {
        method:  'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ endpoint: sub.endpoint }),
    })

    // Désinscrire côté navigateur
    await sub.unsubscribe()
}
```

---

### 4. Appeler au bon moment

```js
// main.js ou dans le store Vuex/Pinia après login

import { registerPush }   from '@/services/registerPush'
import { unregisterPush } from '@/services/unregisterPush'

// Après login réussi :
await registerPush(authToken)

// Avant logout :
await unregisterPush(authToken)
```

---

## Flux complet — Commande client → Notification admin

```
Client passe commande (frontend ecommerce)
    │
    ▼
POST /api/orders  (Laravel)
    │
    ├── Crée la commande en base
    │
    └── SendPushJob::dispatch(shopId, payload)
            │
            ▼
        PushService::sendToShop()
            │
            ├── Récupère toutes les subscriptions du shop
            ├── WebPush::queueNotification() pour chaque device
            └── WebPush::flush() → envoie vers les serveurs Google/Mozilla
                    │
                    ▼
            Navigateur admin reçoit la notification
            Service Worker l'affiche
            Clic → redirige vers /admin/orders/{id}
```

---

## Checklist avant mise en production

| Point | À vérifier |
|---|---|
| **HTTPS obligatoire** | Service Workers et PushManager refusés en HTTP (localhost OK) |
| **Clés VAPID** | Générées une seule fois, ne jamais les changer en prod |
| **`VAPID_SUBJECT`** | Doit être `mailto:...` ou une URL valide |
| **SW enregistré** | DevTools → Application → Service Workers → statut "activated" |
| **Permission utilisateur** | Demander après une action volontaire, pas au chargement |
| **Jobs async** | Utiliser un Job pour ne pas bloquer la réponse HTTP |
| **Nettoyage subscriptions** | `isSubscriptionExpired()` → supprimer les endpoints morts |
| **Multi-device** | Un utilisateur peut avoir plusieurs subscriptions (mobile + desktop) |
| **Safari iOS** | Supporté depuis iOS 16.4+ en mode PWA uniquement |

---

## Structure des fichiers de référence dans ce projet

```
api-shop/
├── app/v1/PushNotification/
│   ├── Models/PushSubscription.php          ← modèle subscription
│   ├── Services/PushNotificationService.php ← service d'envoi
│   ├── Http/Controllers/
│   │   └── PushNotificationController.php   ← save / delete
│   └── routes/api.php                       ← endpoints
│
└── app/v1/Ecommerce/Jobs/
    └── SendOrderNotificationJob.php          ← exemple d'usage en Job

admin-shop/
└── src/services/
    ├── registerPush.js                       ← abonnement après login
    └── unregisterPush.js                     ← désinscription au logout
```
