Files
iistwin/client/public/sw.js
Ильяс Султанов 5e8103a6da fix(push): bump SW to v12, улучшен openOrFocusChat и notificationclick
- openOrFocusChat: includeUncontrolled=true, логирование, try/catch, fallback на clients.openWindow.
- notificationclick: логирование data, fallback построения URL из вложенного data.data.
- markNotificationRead: поддержка type/messageId/conversationId на верхнем уровне и во вложенном data.
- Bump CACHE_NAME до v12 для принудительного обновления SW.
2026-08-26 14:54:37 +03:00

726 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const CACHE_NAME = 'iistwin-pwa-v12';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.svg'
];
// Активный чат, о котором сообщает клиент через 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;
}
});