const CACHE_NAME = 'iistwin-pwa-v13'; const STATIC_ASSETS = [ '/', '/index.html', '/manifest.json', '/favicon.svg', // Self-hosted шрифты — precache, чтобы PWA офлайн рендерился без FOUT/fallback // (имена файлов должны совпадать с @font-face в client/src/index.css) '/fonts/inter-var-latin.woff2', '/fonts/inter-var-latin-ext.woff2', '/fonts/inter-var-cyrillic.woff2', '/fonts/inter-var-cyrillic-ext.woff2', '/fonts/dm-mono-400-latin.woff2', '/fonts/dm-mono-400-latin-ext.woff2', '/fonts/dm-mono-500-latin.woff2', '/fonts/dm-mono-500-latin-ext.woff2' ]; // Активный чат, о котором сообщает клиент через postMessage. // Используется для подавления push-уведомлений, когда пользователь уже // смотрит в соответствующий чат. let activeChat = null; // { type: 'messenger'|'task', id: number, visible: boolean, updatedAt: number } // ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ──── // Schema history: // v1 – `queue` store (keyPath: id, index: enqueuedAt) // v2 – no structural change (conflictStatus field added to queue items) // v3 – `inbox-cache`, `task-cache`, `form-cache` added for PWA offline mode // v4 – `directory-cache` added for offline справочник lookups // v5 – `attachment-queue` для фоновой отправки сообщений с файлами // v6 – synced-* stores для офлайн-кэша форм/задач/справочников // v7 – queue token/orgId fields nullable for cookie-based auth const IDB_NAME = 'iistwin-offline'; const IDB_VERSION = 7; const IDB_STORE = 'queue'; function openQueueDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = (e) => { const db = e.target.result; // v1 if (!db.objectStoreNames.contains('queue')) { const store = db.createObjectStore('queue', { keyPath: 'id' }); store.createIndex('enqueuedAt', 'enqueuedAt', { unique: false }); } // v2: no structural change // v3 if (!db.objectStoreNames.contains('inbox-cache')) { db.createObjectStore('inbox-cache', { keyPath: 'userId' }); } if (!db.objectStoreNames.contains('task-cache')) { db.createObjectStore('task-cache', { keyPath: 'taskId' }); } if (!db.objectStoreNames.contains('form-cache')) { db.createObjectStore('form-cache', { keyPath: 'formId' }); } // v4 if (!db.objectStoreNames.contains('directory-cache')) { db.createObjectStore('directory-cache', { keyPath: 'tableId' }); } // v5: attachment-queue для фоновой отправки сообщений с файлами if (!db.objectStoreNames.contains('attachment-queue')) { const aqStore = db.createObjectStore('attachment-queue', { keyPath: 'id' }); aqStore.createIndex('enqueuedAt', 'enqueuedAt', { unique: false }); } // v6/v7: synced-* stores для офлайн-кэша форм и задач const syncedStoreNames = [ 'synced-forms', 'synced-tasks', 'synced-task-field-values', 'synced-data-tables', 'synced-data-table-rows', 'synced-automations', 'sync-subscriptions', 'sync-meta', 'pending-mutations', 'synced-users', ]; for (const name of syncedStoreNames) { if (!db.objectStoreNames.contains(name)) { if (name === 'synced-task-field-values') { const store = db.createObjectStore(name, { keyPath: 'id' }); store.createIndex('taskId', 'taskId', { unique: false }); } else if (name === 'synced-data-table-rows') { const store = db.createObjectStore(name, { keyPath: 'id' }); store.createIndex('tableId', 'tableId', { unique: false }); } else { db.createObjectStore(name, { keyPath: 'id' }); } } } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function getAllQueueItems(db) { return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).index('enqueuedAt').getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function removeQueueItem(db, id) { return new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } function isSafeToDrop(url, method) { return method === 'POST' && /\/api\/messages\/\d+\/read$/.test(url); } function fetchWithTimeout(request, timeoutMs) { return new Promise((resolve, reject) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); fetch(request, { signal: controller.signal }) .then((response) => { clearTimeout(timer); resolve(response); }) .catch((error) => { clearTimeout(timer); reject(error); }); }); } const ATTACHMENT_STORE = 'attachment-queue'; function getAllAttachmentItems(db) { return new Promise((resolve, reject) => { const tx = db.transaction(ATTACHMENT_STORE, 'readonly'); const req = tx.objectStore(ATTACHMENT_STORE).index('enqueuedAt').getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function removeAttachmentItem(db, id) { return new Promise((resolve, reject) => { const tx = db.transaction(ATTACHMENT_STORE, 'readwrite'); tx.objectStore(ATTACHMENT_STORE).delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } function updateAttachmentItem(db, item) { return new Promise((resolve, reject) => { const tx = db.transaction(ATTACHMENT_STORE, 'readwrite'); tx.objectStore(ATTACHMENT_STORE).put(item); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function uploadFileFromSW(file) { const formData = new FormData(); formData.append('file', file.blob, file.name); const res = await fetch('/api/upload', { method: 'POST', body: formData, credentials: 'include', }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Upload failed: ${res.status}`); } return res.json(); } async function sendMessageFromSW(item, attachments) { const url = item.type === 'messenger' ? `/api/messenger/conversations/${item.targetId}/messages` : `/api/tasks/${item.targetId}/messages`; const payload = { message: item.message || '', attachments, }; if (item.replyToId || item.replyToMessageId) { if (item.type === 'messenger') { payload.replyToId = item.replyToId || item.replyToMessageId; } else { payload.replyToMessageId = item.replyToMessageId || item.replyToId; } } if (item.mentionedUserIds && item.mentionedUserIds.length > 0) { payload.mentionedUserIds = item.mentionedUserIds; } if (item.mentionedBotIds && item.mentionedBotIds.length > 0) { payload.mentionedBotIds = item.mentionedBotIds; } const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'include', }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Send failed: ${res.status}`); } return res.json(); } /** Replay attachment queue directly from the SW. */ async function replayAttachmentQueueFromSW() { let db; try { db = await openQueueDB(); } catch { console.log('[SW] Cannot open IndexedDB for attachment replay'); return; } const items = await getAllAttachmentItems(db); console.log(`[SW] Replaying ${items.length} attachment operation(s) from SW`); for (const item of items) { // Skip items already in error state (need user action or app open to retry) if (item.status === 'error') { console.log(`[SW] Skipping attachment ${item.id} — error state`); continue; } try { const attachments = []; for (const file of item.files || []) { const data = await uploadFileFromSW(file); attachments.push({ url: data.url, name: data.name, size: data.size, mimeType: file.type || undefined, }); } await sendMessageFromSW(item, attachments); await removeAttachmentItem(db, item.id); console.log(`[SW] Replayed attachment ${item.id} → success`); } catch (err) { console.log(`[SW] Attachment replay error ${item.id}:`, err.message); // Network errors: stop and retry later // 401/403: mark error (session may be expired, need app open) // 4xx validation errors: mark error // 5xx/429: stop and retry later if (err.message && (err.message.includes('401') || err.message.includes('403'))) { item.status = 'error'; item.error = 'Требуется повторный вход'; await updateAttachmentItem(db, item); continue; } if (err.message && /\b4\d\d\b/.test(err.message)) { item.status = 'error'; item.error = err.message; await updateAttachmentItem(db, item); continue; } // Transient error — stop replay, next sync will retry break; } } } /** Replay the offline queue directly from the SW (used when no window clients exist). */ async function replayQueueFromSW() { let db; try { db = await openQueueDB(); } catch { console.log('[SW] Cannot open IndexedDB for replay'); return; } const items = await getAllQueueItems(db); console.log(`[SW] Replaying ${items.length} queued request(s) from SW`); for (const item of items) { try { const headers = { 'Content-Type': 'application/json' }; const res = await fetch(item.url, { method: item.method, headers, body: item.body ?? undefined, credentials: 'include', }); if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429 && isSafeToDrop(item.url, item.method))) { // Success or safe permanent client error — remove from queue await removeQueueItem(db, item.id); console.log(`[SW] Replayed ${item.method} ${item.url} → ${res.status}`); } else { // Server error, 429, or non-safe 4xx — stop and let the app handle it console.log(`[SW] Replay stopped at ${item.method} ${item.url} → ${res.status}`); break; } } catch { // Network error during SW replay — stop console.log(`[SW] Replay network error at ${item.method} ${item.url}`); break; } } } // ── Lifecycle ───────────────────────────────────────────────────────────────── self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('[SW] Caching static assets'); return cache.addAll(STATIC_ASSETS); }) ); self.skipWaiting(); }); self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name !== CACHE_NAME) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) ); self.clients.claim(); }); async function handleShareTarget(request) { try { const formData = await request.formData(); const files = formData.getAll('media'); const title = formData.get('title') || ''; const text = formData.get('text') || ''; const url = formData.get('url') || ''; const cache = await caches.open('share-target-files'); await cache.put('/share-target/data', new Response(JSON.stringify({ title: String(title), text: String(text) + (url ? '\n' + url : ''), fileCount: files.length, fileNames: files.map(f => (f instanceof File ? f.name : 'file')), timestamp: Date.now(), }), { headers: { 'Content-Type': 'application/json' } })); for (let i = 0; i < files.length; i++) { const file = files[i]; if (file instanceof Blob) { await cache.put(`/share-target/file/${i}`, new Response(file, { headers: { 'Content-Type': file.type || 'application/octet-stream' } })); } } } catch (err) { console.error('[SW] Share target error:', err); } return Response.redirect('/share-target?shared=1', 303); } self.addEventListener('fetch', (event) => { if (event.request.url.includes('/share-target') && event.request.method === 'POST') { event.respondWith(handleShareTarget(event.request)); return; } if (event.request.method !== 'GET') return; const url = new URL(event.request.url); // Skip API requests if (url.pathname.startsWith('/api/')) return; // Navigation requests (page loads): network-first with a short timeout, // falling back to the cached shell. This guarantees that users see the // latest build when they are online, while still keeping the app usable // during cold PWA starts or flaky connections. if (event.request.mode === 'navigate') { event.respondWith( fetchWithTimeout(event.request, 3000) .then(async (networkResponse) => { if (networkResponse.status === 200) { const clone = networkResponse.clone(); const cache = await caches.open(CACHE_NAME); await cache.put('/index.html', clone); } return networkResponse; }) .catch(async () => { const cached = await caches.match('/index.html'); if (cached) return cached; const rootFallback = await caches.match('/'); if (rootFallback) return rootFallback; return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); }) ); return; } event.respondWith( fetchWithTimeout(event.request, 5000) .then((response) => { if (response.status === 200) { const responseClone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, responseClone); }); } return response; }) .catch(() => caches.match(event.request)) ); }); // ── Background Sync ─────────────────────────────────────────────────────────── self.addEventListener('sync', (event) => { if (event.tag === 'offline-queue') { console.log('[SW] Background sync triggered: offline-queue'); event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: false }).then(async (clientList) => { if (clientList.length > 0) { // App is open — tell it to handle sync (it has access to React Query cache) for (const client of clientList) { client.postMessage({ type: 'TRIGGER_SYNC' }); } } else { // App is closed — replay queue directly from SW await replayQueueFromSW(); } }) ); } if (event.tag === 'attachment-queue') { console.log('[SW] Background sync triggered: attachment-queue'); event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: false }).then(async (clientList) => { if (clientList.length > 0) { // App is open — let the app handle it so React Query cache is updated for (const client of clientList) { client.postMessage({ type: 'TRIGGER_ATTACHMENT_SYNC' }); } } else { // App is closed — replay attachment queue directly from SW await replayAttachmentQueueFromSW(); } }) ); } if (event.tag === 'offline-sync') { console.log('[SW] Background sync triggered: offline-sync'); event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: false }).then((clientList) => { if (clientList.length > 0) { // App is open — let the full sync engine run in the window context for (const client of clientList) { client.postMessage({ type: 'TRIGGER_SYNC' }); } } // When the app is closed we cannot run the full sync engine (it needs // React Query and the authenticated fetch layer). The queued mutations // will be replayed by the offline-queue tag instead. }) ); } }); // ── Push notifications ──────────────────────────────────────────────────────── async function hasVisibleClient() { const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); return clientList.some((client) => client.visibilityState === 'visible'); } async function sendToForegroundClients(payload) { const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); for (const client of clientList) { client.postMessage({ type: 'FOREGROUND_NOTIFICATION', payload }); } } function shouldSuppressPushForActiveChat(payload) { if (!activeChat || !activeChat.visible) return false; // Игнорируем устаревшие записи. const maxAgeMs = 60 * 1000; if (!activeChat.updatedAt || Date.now() - activeChat.updatedAt > maxAgeMs) return false; const nestedType = payload.data?.type; if (nestedType === 'messenger' && activeChat.type === 'messenger') { return parseInt(payload.data?.conversationId, 10) === activeChat.id; } if ((nestedType === 'chat' || payload.type === 'chat') && activeChat.type === 'task') { return parseInt(payload.data?.taskId || payload.taskId, 10) === activeChat.id; } return false; } self.addEventListener('push', (event) => { console.log('[SW] Push notification received'); let data = { title: 'Уведомление', body: 'Новое сообщение', icon: '/icons/icon-192x192.png', url: '/', }; if (event.data) { try { data = { ...data, ...event.data.json() }; } catch { data.body = event.data.text(); } } // Silent sync trigger: do not show a notification, just request a background sync. // Support both the new top-level type and the old nested data.type shape. if (data.type === 'SYNC_NEEDED' || data.data?.type === 'SYNC_NEEDED') { event.waitUntil( self.registration.sync ? self.registration.sync.register('offline-sync').catch(() => {}) : Promise.resolve() ); return; } // Подавляем push, если пользователь уже смотрит в этот чат на активной вкладке. if (shouldSuppressPushForActiveChat(data)) { console.log('[SW] Push suppressed for active chat:', activeChat); return; } event.waitUntil( hasVisibleClient().then((visible) => { if (visible) { // Active tab gets an in-app toast instead of a system notification. return sendToForegroundClients(data); } const options = { body: data.body || data.message || '', icon: data.icon || '/icons/icon-192x192.png', badge: '/icons/icon-192x192.png', vibrate: [100, 50, 100], data: { url: data.url || data.click_action || '/', ...data }, actions: data.actions || [], // Keep the notification on screen until the user interacts with it // when the tab is not active. requireInteraction: true, // Use a unique tag per task so multiple task notifications stack instead of // replacing each other, and avoid renotify which can suppress display on some OSs. tag: data.tag || (data.taskId ? `task-${data.taskId}` : `notif-${Date.now()}`), }; return self.registration.showNotification(data.title, options); }) ); }); async function openOrFocusChat(url) { const absoluteUrl = new URL(url, self.location.origin).href; console.log('[SW] openOrFocusChat:', absoluteUrl); try { // Ищем любые окна (включая неподконтрольные) — focus работает и на них, // а navigate может не сработать на uncontrolled, поэтому оборачиваем в try/catch. const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); console.log('[SW] clients found:', clientList.length); // Сначала ищем клиент с уже открытым этим URL — просто фокусируем. for (const client of clientList) { try { if (client.url === absoluteUrl && 'focus' in client) { console.log('[SW] focusing existing client with same URL'); return await client.focus(); } } catch (err) { console.error('[SW] focus same-url client error:', err); } } // Затем пытаемся навигировать любой доступный клиент. for (const client of clientList) { try { if ('focus' in client) { console.log('[SW] navigating client to', absoluteUrl); await client.navigate(absoluteUrl); return await client.focus(); } } catch (err) { console.error('[SW] navigate client error:', err); } } // Нет подходящих клиентов — открываем новое окно/вкладку. console.log('[SW] opening new window'); const newClient = await clients.openWindow(absoluteUrl); if (newClient && 'focus' in newClient) { return await newClient.focus(); } return newClient; } catch (err) { console.error('[SW] openOrFocusChat fatal error:', err); // Последний fallback — всё равно пытаемся открыть окно. return clients.openWindow(absoluteUrl); } } async function markNotificationRead(data) { try { // Поддерживаем оба формата: type на верхнем уровне и вложенный data.type const type = data?.type || data?.data?.type; const messageId = data?.messageId || data?.data?.messageId; const conversationId = data?.conversationId || data?.data?.conversationId; if (type === 'chat' && messageId) { await fetch(`/api/messages/${messageId}/read`, { method: 'POST', credentials: 'include', }); console.log('[SW] Marked chat message as read:', messageId); } else if (type === 'messenger' && conversationId) { await fetch(`/api/messenger/conversations/${conversationId}/read`, { method: 'POST', credentials: 'include', }); console.log('[SW] Marked messenger conversation as read:', conversationId); } } catch (err) { console.error('[SW] Failed to mark notification as read:', err); } } self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked, action:', event.action); event.notification.close(); const data = event.notification.data || {}; console.log('[SW] Notification data:', JSON.stringify(data)); // Fallback: если url отсутствует, пытаемся восстановить его из вложенного data. let urlToOpen = data.url || '/'; if ((!data.url || data.url === '/') && data.data) { const nested = data.data; if (nested.type === 'chat' && nested.taskId && nested.formId) { urlToOpen = `/forms/${nested.formId}/tasks/${nested.taskId}${nested.isChat === 'true' ? '?tab=chat' : ''}`; console.log('[SW] Rebuilt url from nested data:', urlToOpen); } else if (nested.type === 'messenger' && nested.conversationId) { urlToOpen = `/chat/${nested.conversationId}`; console.log('[SW] Rebuilt url from nested data:', urlToOpen); } } if (event.action === 'read') { // "Прочитано": помечаем прочитанным и всё равно открываем задачу/чат, как ожидает пользователь. event.waitUntil( Promise.all([ navigator.clearAppBadge ? navigator.clearAppBadge() : Promise.resolve(), markNotificationRead(data.data || data), openOrFocusChat(urlToOpen), ]) ); return; } // Body click or 'reply' action: open the linked task/chat and focus an existing tab if possible. event.waitUntil( Promise.all([ navigator.clearAppBadge ? navigator.clearAppBadge() : Promise.resolve(), openOrFocusChat(urlToOpen), ]) ); }); // ── Push subscription lifecycle ─────────────────────────────────────────────── self.addEventListener('pushsubscriptionchange', (event) => { console.log('[SW] Push subscription changed'); event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { if (clientList.length > 0) { // Ask the app to resubscribe and register the new subscription with the server. for (const client of clientList) { client.postMessage({ type: 'PUSH_SUBSCRIPTION_CHANGE', oldEndpoint: event.oldSubscription?.endpoint }); } } else { // No open clients — we cannot fetch a new subscription without user interaction. // The next app start will detect the missing/expired subscription and re-register. console.log('[SW] No window clients to handle pushsubscriptionchange'); } }) ); }); self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') { self.skipWaiting(); return; } if (event.data?.type === 'ACTIVE_CHAT') { const payload = event.data?.payload || {}; activeChat = { type: payload.type, id: payload.id, visible: !!payload.visible, updatedAt: Date.now(), }; return; } if (event.data?.type === 'CLEAR_ACTIVE_CHAT') { activeChat = null; } });