feat: Phase 3 complete — push alerts, feedback scoring, S3 fallback, SPA routing, mobile CSS

- ImpactScoreService: implement getFeedbackBonus() (ST_Distance query on UserFeedback within 20km/6h)
- ImpactScoreService: trigger PushNotificationService when score >= 70
- PushNotificationService: send VAPID WebPush to spot subscribers, clean expired subs
- PushController: GET vapid-public-key, POST/DELETE subscribe with rate limiting
- SentinelHubClient: add optional $collection param, add Sentinel-3 OLCI FAI evalscript (MCI)
- IngestionService: add $collection param + HighCloudCoverageException for fallback logic
- IngestionService: add ingestWithFallback() — tries S2, falls back to S3 on high cloud
- IngestSentinelCommand: --source=auto (default) triggers ingestWithFallback
- FeedbackController: rate limiting via apiFeedbackLimiter
- Migration: push_subscription table
- rate_limiter.yaml: api_read(120/min), api_feedback(10/min), api_push(5/min)
- sw.js: service worker handling push events + notificationclick
- usePushSubscription hook: subscribe/unsubscribe lifecycle with VAPID
- SpotPanel: PushButton component integrated
- SpotPanel.css + TimelineSlider.css: mobile responsive (bottom-sheet on small screens)
- Caddyfile: SPA served at / with try_files fallback, sw.js served from root scope
- vite.config.js: build outDir → backend/public (not /spa)
- deploy/post-receive.sh: full deploy script (composer, npm build, migrations, cache, docker up)
- docs/roadmap.md: all Phase 3 + transversal items marked done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:43:56 -04:00
parent 94bb6f5e8c
commit 2cef3725e9
24 changed files with 780 additions and 42 deletions

View File

@@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import { api } from '../api/client';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = window.atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
/**
* Hook managing the Web Push subscription lifecycle for a given coastalPointId.
* Returns { supported, subscribed, loading, error, toggle }
*/
export default function usePushSubscription(coastalPointId) {
const [supported, setSupported] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setSupported('serviceWorker' in navigator && 'PushManager' in window);
}, []);
useEffect(() => {
if (!supported) return;
navigator.serviceWorker.ready.then((reg) =>
reg.pushManager.getSubscription()
).then((sub) => {
setSubscribed(sub !== null);
}).catch(() => {});
}, [supported]);
const toggle = async () => {
if (!supported || loading) return;
setLoading(true);
setError(null);
try {
const reg = await navigator.serviceWorker.ready;
const existingSub = await reg.pushManager.getSubscription();
if (existingSub) {
await api.push.unsubscribe(existingSub.endpoint).catch(() => {});
await existingSub.unsubscribe();
setSubscribed(false);
} else {
const { publicKey } = await api.push.vapidKey();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const json = sub.toJSON();
await api.push.subscribe({
endpoint: json.endpoint,
keys: json.keys,
coastalPointId,
});
setSubscribed(true);
}
} catch (e) {
setError(e.message ?? 'Erreur abonnement push');
} finally {
setLoading(false);
}
};
return { supported, subscribed, loading, error, toggle };
}