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) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
packages/server/src/routes/push.ts
Normal file
35
packages/server/src/routes/push.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user