Round 13: PWA Offline Mode, Rate Limiting, Behavior Analytics, Interventions, Audit Logging

This commit is contained in:
Mustafa Cetin committed 2026-07-29 10:23:50 +00:00
1 parent ceea8fb591
commit ff4dd670d4
33 files changed
+6327 -509

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"name": "CompetenceTrack",
"short_name": "CompTrack",
"description": "Kompetenz- und Benotungsplattform für Schulen",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#10b981",
"orientation": "any",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["education", "productivity"],
"lang": "de",
"dir": "ltr"
}
+300
View File
@@ -0,0 +1,300 @@
// CompetenceTrack — Service Worker for PWA Offline Support
const CACHE_NAME = 'competencetrack-v1';
const STATIC_CACHE = 'competencetrack-static-v1';
const DATA_CACHE = 'competencetrack-data-v1';
const OFFLINE_CACHE = 'competencetrack-offline-v1';
// Static assets to cache on install
const STATIC_ASSETS = [
'/',
'/manifest.json',
'/logo.svg',
'/robots.txt',
];
// Install event — cache static assets and offline fallback
self.addEventListener('install', (event) => {
event.waitUntil(
Promise.all([
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(STATIC_ASSETS).catch((err) => {
console.warn('[SW] Some static assets failed to cache:', err);
});
}),
caches.open(OFFLINE_CACHE).then((cache) => {
return cache.add('/').catch(() => {
// Fallback: just create the cache
});
}),
]).then(() => {
console.log('[SW] Installed and caches populated');
return self.skipWaiting();
})
);
});
// Activate event — clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE && key !== DATA_CACHE && key !== OFFLINE_CACHE)
.map((key) => caches.delete(key))
);
}).then(() => {
console.log('[SW] Activated and old caches cleaned');
return self.clients.claim();
})
);
});
// Fetch event — routing strategy
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests (POST, PUT, DELETE) — handle with background sync
if (request.method !== 'GET') {
// Queue write operations for background sync
if (navigator.onLine === false) {
event.respondWith(
new Response(JSON.stringify({ queued: true, offline: true }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
})
);
// Store the request for later sync
storeRequestForSync(request);
return;
}
return; // Let non-GET requests go through normally when online
}
// Skip chrome-extension and other non-http requests
if (!url.protocol.startsWith('http')) return;
// API data requests — NetworkFirst strategy
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
return;
}
// Static assets (CSS, JS, images) — CacheFirst strategy
if (
url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.svg') ||
url.pathname.endsWith('.ico') ||
url.pathname.endsWith('.woff2') ||
url.pathname.endsWith('.woff')
) {
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
return;
}
// Navigation requests — NetworkFirst with offline fallback
if (request.mode === 'navigate') {
event.respondWith(networkFirstWithOfflineFallback(request));
return;
}
// Default: NetworkFirst
event.respondWith(networkFirstStrategy(request, CACHE_NAME));
});
// NetworkFirst strategy — try network, fall back to cache
async function networkFirstStrategy(request, cacheName) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline response for API calls
if (new URL(request.url).pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'offline', message: 'No cached data available' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Offline', { status: 503 });
}
}
// CacheFirst strategy — try cache, fall back to network
async function cacheFirstStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// No cache and no network
return new Response('Offline', { status: 503 });
}
}
// NetworkFirst with offline fallback page
async function networkFirstWithOfflineFallback(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Fall back to cached root page
const fallbackResponse = await caches.match('/');
if (fallbackResponse) {
return fallbackResponse;
}
return new Response('Offline — Please check your connection', {
status: 503,
headers: { 'Content-Type': 'text/html' },
});
}
}
// Store failed requests for background sync
async function storeRequestForSync(request) {
const db = await openIndexedDB();
const tx = db.transaction('pending-requests', 'readwrite');
const store = tx.objectStore('pending-requests');
const requestData = {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.text().catch(() => ''),
timestamp: Date.now(),
};
store.add(requestData);
await tx.done;
// Register for background sync
self.registration.sync.register('competencetrack-sync');
}
// Open IndexedDB for storing pending requests
function openIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('competencetrack-sync-db', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('pending-requests')) {
db.createObjectStore('pending-requests', { keyPath: 'timestamp' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Background sync event — replay queued requests when back online
self.addEventListener('sync', (event) => {
if (event.tag === 'competencetrack-sync') {
event.waitUntil(replayPendingRequests());
}
});
async function replayPendingRequests() {
const db = await openIndexedDB();
const tx = db.transaction('pending-requests', 'readwrite');
const store = tx.objectStore('pending-requests');
const allRequests = await store.getAll();
for (const reqData of allRequests) {
try {
const headers = new Headers(reqData.headers);
const response = await fetch(reqData.url, {
method: reqData.method,
headers,
body: reqData.method !== 'GET' ? reqData.body : undefined,
});
if (response.ok) {
store.delete(reqData.timestamp);
}
} catch (error) {
console.warn('[SW] Failed to replay request:', error);
// Keep the request in the store for next sync attempt
}
}
await tx.done;
}
// Push event — show notification (if push notifications are configured)
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.message || '',
icon: '/icon-192.png',
badge: '/icon-192.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
};
event.waitUntil(
self.registration.showNotification(data.title || 'CompetenceTrack', options)
);
});
// Notification click — open the app
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
// Focus existing window if available
for (const client of clients) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
// Open new window
return self.clients.openWindow(url);
})
);
});
// Message event — handle messages from the app
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHES') {
caches.keys().then((keys) => {
Promise.all(keys.map((key) => caches.delete(key))).then(() => {
console.log('[SW] All caches cleared');
});
});
}
if (event.data && event.data.type === 'CACHE_URLS') {
const urls = event.data.urls || [];
caches.open(STATIC_CACHE).then((cache) => {
cache.addAll(urls).catch((err) => {
console.warn('[SW] Failed to cache URLs:', err);
});
});
}
});