P4-B: notifications Web Push (VAPID) sur passage en waiting

- db: migration id=4 `push_subscriptions` (liées au token d'auth) ; clés VAPID en settings
- PushService: bootstrap idempotent des clés VAPID, subscribe/unsubscribe/count,
  notify() best-effort (purge des abonnements 410/404 Gone) ; sender injectable pour les tests
- routes/push.ts: GET vapid-public-key, POST subscribe/unsubscribe/test (toutes sous auth globale)
- pty-manager: déclencheur push sur FRONT MONTANT vers waiting, débouncé 1500ms et annulable
  (faux positif ignoré) ; câblage app/index/config (--vapid-contact)
- shared/api.ts: types VapidKeyResponse / PushSubscribeRequest / PushUnsubscribeRequest
- deps: web-push (+ @types/web-push) côté serveur
- tests: push-service (idempotence, UPSERT, 410-purge, payload) + trigger pty-manager (169 verts)
This commit is contained in:
2026-06-15 12:13:56 +02:00
parent 25d6d09d24
commit 664e2b583c
13 changed files with 493 additions and 11 deletions

View File

@@ -0,0 +1,35 @@
// Routes Web Push (P4). Toutes sous l'auth globale (preValidation) : aucune route publique.
// L'abonnement est lié au token authentifié (req.authContext.tokenId).
import type { FastifyInstance } from 'fastify';
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
import type { PushService } from '../core/push-service.js';
export function registerPushRoutes(app: FastifyInstance, push: PushService): void {
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
app.post('/api/v1/push/subscribe', async (req, reply) => {
const body = req.body as Partial<PushSubscribeRequest> | null;
if (!body || typeof body.endpoint !== 'string' || !body.keys || typeof body.keys.p256dh !== 'string' || typeof body.keys.auth !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint and keys{p256dh,auth} are required' } });
}
// garanti non-null : la route est protégée par le preValidation global.
const tokenId = req.authContext!.tokenId;
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
return reply.status(201).send({ ok: true });
});
app.post('/api/v1/push/unsubscribe', async (req, reply) => {
const body = req.body as Partial<PushUnsubscribeRequest> | null;
if (!body || typeof body.endpoint !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
}
push.unsubscribe(body.endpoint);
return reply.send({ ok: true });
});
// Notification de test (diagnostic mobile) : pousse vers tous les abonnements de l'utilisateur.
app.post('/api/v1/push/test', async (_req, reply) => {
await push.notify({ sessionId: 'test', title: 'Arboretum', body: 'Push notifications are working.', kind: null, url: '/' });
return reply.send({ ok: true });
});
}