fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

624
client/public/sw.js Normal file
View File

@@ -0,0 +1,624 @@
const CACHE_NAME = 'iistwin-pwa-v10';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.svg'
];
// ── 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 });
}
}
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;
}
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 clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true });
// Prefer a client that already has the same path (ignoring query string).
const targetPath = url.split('?')[0];
for (const client of clientList) {
if (client.url.includes(self.location.origin) && client.url.split('?')[0] === targetPath && 'focus' in client) {
await client.navigate(url);
return client.focus();
}
}
// Otherwise focus any same-origin client and navigate it.
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
await client.navigate(url);
return client.focus();
}
}
return clients.openWindow(url);
}
async function markNotificationRead(data) {
try {
if (data?.type === 'chat' && data?.messageId) {
await fetch(`/api/messages/${data.messageId}/read`, {
method: 'POST',
credentials: 'include',
});
console.log('[SW] Marked chat message as read:', data.messageId);
} else if (data?.type === 'messenger' && data?.conversationId) {
await fetch(`/api/messenger/conversations/${data.conversationId}/read`, {
method: 'POST',
credentials: 'include',
});
console.log('[SW] Marked messenger conversation as read:', data.conversationId);
}
} catch (err) {
console.error('[SW] Failed to mark notification as read:', err);
}
}
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked', event.action);
event.notification.close();
const data = event.notification.data || {};
const urlToOpen = data.url || '/';
if (event.action === 'read') {
event.waitUntil(markNotificationRead(data));
return;
}
// 'reply' or body click: open the 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();
}
});