415 lines
12 KiB
JavaScript
415 lines
12 KiB
JavaScript
/**
|
|
* Sync Manager - iOS-compatible sync orchestration
|
|
*
|
|
* Background Sync API is NOT supported on iOS Safari.
|
|
* This manager uses visibility-based and online-event sync as primary strategy.
|
|
*/
|
|
|
|
import { db, getPendingCount as getDbPendingCount } from './db.js';
|
|
import { isOfflineModeEnabled, isAutoSyncEnabled, updateLastSyncTimestamp } from './offlineSettings.js';
|
|
import syncQueue from './syncQueue.js';
|
|
import photoQueue from './photoQueue.js';
|
|
import workorderService from './workorderOfflineService.js';
|
|
|
|
const API_BASE = '/MobileApp/Workorder/Workorder';
|
|
|
|
// Sync state
|
|
const syncState = {
|
|
isSyncing: false,
|
|
lastSyncAttempt: null,
|
|
lastSyncSuccess: null,
|
|
syncError: null,
|
|
listeners: new Set()
|
|
};
|
|
|
|
// Minimum time between auto-syncs (prevent rapid fire)
|
|
const MIN_SYNC_INTERVAL = 30000; // 30 seconds
|
|
|
|
/**
|
|
* Notify listeners of state change
|
|
*/
|
|
function notifyListeners(event, data) {
|
|
for (const listener of syncState.listeners) {
|
|
try {
|
|
listener(event, data);
|
|
} catch (error) {
|
|
console.error('[SyncManager] Listener error:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to sync events
|
|
*/
|
|
export function subscribe(listener) {
|
|
syncState.listeners.add(listener);
|
|
return () => syncState.listeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* Get current sync state
|
|
*/
|
|
export function getSyncState() {
|
|
return { ...syncState };
|
|
}
|
|
|
|
/**
|
|
* Check if sync is currently running
|
|
*/
|
|
export function isSyncing() {
|
|
return syncState.isSyncing;
|
|
}
|
|
|
|
/**
|
|
* Initialize sync manager
|
|
* Sets up event listeners for iOS-compatible sync
|
|
*/
|
|
export function initSyncManager() {
|
|
// Primary iOS strategy: sync when app becomes visible
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
// Sync when network reconnects
|
|
window.addEventListener('online', handleOnline);
|
|
|
|
// Also handle page show (for bfcache restoration)
|
|
window.addEventListener('pageshow', handlePageShow);
|
|
|
|
// Initial sync check on load
|
|
window.addEventListener('load', () => {
|
|
if (navigator.onLine && isOfflineModeEnabled()) {
|
|
// Delay initial sync slightly to let app stabilize
|
|
setTimeout(() => maybeTriggerSync('load'), 2000);
|
|
}
|
|
});
|
|
|
|
// Try to register for Background Sync (Chrome/Edge only)
|
|
registerBackgroundSync().catch(() => {
|
|
console.log('[SyncManager] Background Sync not available, using fallback');
|
|
});
|
|
|
|
console.log('[SyncManager] Initialized');
|
|
}
|
|
|
|
/**
|
|
* Handle visibility change
|
|
*/
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState === 'visible' && navigator.onLine) {
|
|
maybeTriggerSync('visibility');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle online event
|
|
*/
|
|
function handleOnline() {
|
|
maybeTriggerSync('online');
|
|
}
|
|
|
|
/**
|
|
* Handle pageshow event
|
|
*/
|
|
function handlePageShow(event) {
|
|
if (event.persisted && navigator.onLine) {
|
|
// Page was restored from bfcache
|
|
maybeTriggerSync('pageshow');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Maybe trigger sync (with throttling)
|
|
*/
|
|
async function maybeTriggerSync(trigger) {
|
|
if (!isOfflineModeEnabled() || !isAutoSyncEnabled()) {
|
|
return;
|
|
}
|
|
|
|
if (syncState.isSyncing) {
|
|
console.log('[SyncManager] Sync already in progress, skipping');
|
|
return;
|
|
}
|
|
|
|
// Throttle syncs
|
|
const now = Date.now();
|
|
if (syncState.lastSyncAttempt && (now - syncState.lastSyncAttempt) < MIN_SYNC_INTERVAL) {
|
|
console.log('[SyncManager] Sync throttled, too recent');
|
|
return;
|
|
}
|
|
|
|
// Check if there's anything to sync
|
|
const pendingOps = await syncQueue.getPendingCount();
|
|
const pendingPhotos = await photoQueue.getPendingCount();
|
|
|
|
if (pendingOps === 0 && pendingPhotos === 0) {
|
|
console.log('[SyncManager] No pending changes to sync');
|
|
return;
|
|
}
|
|
|
|
console.log(`[SyncManager] Auto-sync triggered by ${trigger} (${pendingOps} ops, ${pendingPhotos} photos)`);
|
|
await performSync();
|
|
}
|
|
|
|
/**
|
|
* Register for Background Sync API (Chrome/Edge only)
|
|
*/
|
|
async function registerBackgroundSync() {
|
|
if ('serviceWorker' in navigator && 'sync' in window.SyncManager?.prototype) {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
if ('sync' in registration) {
|
|
await registration.sync.register('workorder-sync');
|
|
console.log('[SyncManager] Background Sync registered');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform sync - main sync logic
|
|
*/
|
|
export async function performSync(options = {}) {
|
|
if (syncState.isSyncing && !options.force) {
|
|
return { success: false, error: 'Sync already in progress' };
|
|
}
|
|
|
|
if (!navigator.onLine) {
|
|
return { success: false, error: 'Offline' };
|
|
}
|
|
|
|
syncState.isSyncing = true;
|
|
syncState.lastSyncAttempt = Date.now();
|
|
syncState.syncError = null;
|
|
notifyListeners('sync-start', {});
|
|
|
|
const results = {
|
|
operations: { success: 0, failed: 0, total: 0 },
|
|
photos: { success: 0, failed: 0, total: 0 },
|
|
conflicts: [],
|
|
reassigned: []
|
|
};
|
|
|
|
try {
|
|
// 1. Process pending operations
|
|
const pendingOps = await syncQueue.getPending();
|
|
results.operations.total = pendingOps.length;
|
|
|
|
for (const op of pendingOps) {
|
|
notifyListeners('sync-progress', {
|
|
phase: 'operations',
|
|
current: results.operations.success + results.operations.failed + 1,
|
|
total: results.operations.total
|
|
});
|
|
|
|
const opResult = await processOperation(op);
|
|
if (opResult.success) {
|
|
results.operations.success++;
|
|
} else {
|
|
results.operations.failed++;
|
|
if (opResult.conflict) {
|
|
results.conflicts.push({
|
|
operation: op,
|
|
serverData: opResult.serverData
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Upload pending photos
|
|
const pendingPhotos = await photoQueue.getPending();
|
|
results.photos.total = pendingPhotos.length;
|
|
|
|
for (const photo of pendingPhotos) {
|
|
notifyListeners('sync-progress', {
|
|
phase: 'photos',
|
|
current: results.photos.success + results.photos.failed + 1,
|
|
total: results.photos.total,
|
|
fileName: photo.fileName
|
|
});
|
|
|
|
const photoResult = await photoQueue.upload(photo.localId);
|
|
if (photoResult.success) {
|
|
results.photos.success++;
|
|
} else {
|
|
results.photos.failed++;
|
|
}
|
|
}
|
|
|
|
// 3. Check for reassigned workorders (if enabled)
|
|
if (options.checkReassigned !== false) {
|
|
const serverWorkorderIds = await fetchServerWorkorderIds();
|
|
if (serverWorkorderIds) {
|
|
results.reassigned = await workorderService.checkReassignedWorkorders(serverWorkorderIds);
|
|
}
|
|
}
|
|
|
|
// 4. Cleanup old completed items
|
|
await syncQueue.cleanup();
|
|
await photoQueue.cleanup();
|
|
|
|
// Update last sync time
|
|
updateLastSyncTimestamp();
|
|
syncState.lastSyncSuccess = Date.now();
|
|
|
|
console.log('[SyncManager] Sync completed:', results);
|
|
notifyListeners('sync-complete', results);
|
|
|
|
return { success: true, results };
|
|
|
|
} catch (error) {
|
|
console.error('[SyncManager] Sync error:', error);
|
|
syncState.syncError = error.message;
|
|
notifyListeners('sync-error', { error: error.message });
|
|
return { success: false, error: error.message };
|
|
|
|
} finally {
|
|
syncState.isSyncing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process single operation from queue
|
|
*/
|
|
async function processOperation(op) {
|
|
await syncQueue.markProcessing(op.localId);
|
|
|
|
try {
|
|
const result = await executeOperation(op);
|
|
|
|
if (result.success) {
|
|
await syncQueue.markCompleted(op.localId, result);
|
|
return { success: true };
|
|
}
|
|
|
|
if (result.conflict) {
|
|
// Conflict detected - don't retry automatically
|
|
await syncQueue.markFailed(op.localId, 'Konflikt erkannt', false);
|
|
return { success: false, conflict: true, serverData: result.serverData };
|
|
}
|
|
|
|
// Check if retriable
|
|
const retriable = syncQueue.isRetriableError(result._status, result.error);
|
|
await syncQueue.markFailed(op.localId, result.error, retriable);
|
|
return { success: false, error: result.error };
|
|
|
|
} catch (error) {
|
|
await syncQueue.markFailed(op.localId, error.message, true);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute operation via API
|
|
*/
|
|
async function executeOperation(op) {
|
|
const endpoint = getOperationEndpoint(op.operation);
|
|
if (!endpoint) {
|
|
return { success: false, error: `Unknown operation: ${op.operation}` };
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}/${endpoint}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Idempotency-Key': op.operationId,
|
|
'X-Client-Timestamp': String(op.clientTimestamp),
|
|
'X-Device-Id': op.deviceId
|
|
},
|
|
body: JSON.stringify(op.payload)
|
|
});
|
|
|
|
const data = await response.json();
|
|
return { ...data, _status: response.status };
|
|
}
|
|
|
|
/**
|
|
* Map operation type to API endpoint
|
|
*/
|
|
function getOperationEndpoint(operation) {
|
|
const endpoints = {
|
|
'addJournal': 'addJournal',
|
|
'updateNotes': 'updateAdditionalInfo',
|
|
'scheduleAppointment': 'scheduleAppointment',
|
|
'requestIntervention': 'requestIntervention',
|
|
'updateCableData': 'updateWorkorderData',
|
|
'completeWorkorder': 'completeWorkorder'
|
|
};
|
|
return endpoints[operation];
|
|
}
|
|
|
|
/**
|
|
* Fetch server workorder IDs for reassignment check
|
|
*/
|
|
async function fetchServerWorkorderIds() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/getWorkorderIds`, {
|
|
credentials: 'include'
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
return data.success ? data.ids : null;
|
|
}
|
|
} catch (error) {
|
|
console.warn('[SyncManager] Failed to fetch workorder IDs:', error);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Manual sync trigger
|
|
*/
|
|
export async function triggerManualSync() {
|
|
console.log('[SyncManager] Manual sync triggered');
|
|
return performSync({ force: true });
|
|
}
|
|
|
|
/**
|
|
* Get pending changes count
|
|
*/
|
|
export async function getPendingCount() {
|
|
const ops = await syncQueue.getPendingCount();
|
|
const photos = await photoQueue.getPendingCount();
|
|
return ops + photos;
|
|
}
|
|
|
|
/**
|
|
* Get detailed pending summary
|
|
*/
|
|
export async function getPendingSummary() {
|
|
const ops = await syncQueue.getPendingCount();
|
|
const photos = await photoQueue.getPendingCount();
|
|
const failedOps = await syncQueue.getFailed();
|
|
|
|
return {
|
|
operations: ops,
|
|
photos: photos,
|
|
total: ops + photos,
|
|
failed: failedOps.length,
|
|
hasFailures: failedOps.length > 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cleanup and stop sync manager
|
|
*/
|
|
export function destroySyncManager() {
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('pageshow', handlePageShow);
|
|
syncState.listeners.clear();
|
|
console.log('[SyncManager] Destroyed');
|
|
}
|
|
|
|
// Export for service worker communication
|
|
export const SyncManager = {
|
|
init: initSyncManager,
|
|
destroy: destroySyncManager,
|
|
sync: performSync,
|
|
triggerSync: triggerManualSync,
|
|
subscribe,
|
|
getState: getSyncState,
|
|
isSyncing,
|
|
getPendingCount,
|
|
getPendingSummary
|
|
};
|
|
|
|
export default SyncManager;
|