assets_js_services_offline-queue-service.js

/**
 * OfflineQueueService - IndexedDB-backed service for queuing offline actions
 * 
 * Provides persistent storage for actions that need to be synced when online.
 * Uses IndexedDB for reliable cross-session persistence.
 * 
 * Features:
 * - Queue actions for later sync
 * - Persist across browser sessions
 * - Automatic sync when coming online
 * - Progress callbacks during sync
 * - Error tracking for failed actions
 * - CSRF token refresh before sync
 */

const DB_NAME = 'kmp-offline-queue';
const DB_VERSION = 1;
const STORE_NAME = 'pending-actions';

class OfflineQueueService {
    constructor() {
        this.db = null;
        this.isInitialized = false;
        this.syncInProgress = false;
        
        // Bind methods
        this._handleOnline = this._handleOnline.bind(this);
    }

    /**
     * Initialize the IndexedDB database
     * @returns {Promise<void>}
     */
    async init() {
        if (this.isInitialized) return;

        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, DB_VERSION);

            request.onerror = () => {
                console.error('Failed to open offline queue database:', request.error);
                reject(request.error);
            };

            request.onsuccess = () => {
                this.db = request.result;
                this.isInitialized = true;
                
                // Set up online listener for auto-sync
                window.addEventListener('online', this._handleOnline);
                
                console.log('Offline queue service initialized');
                resolve();
            };

            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                
                // Create the pending-actions store
                if (!db.objectStoreNames.contains(STORE_NAME)) {
                    const store = db.createObjectStore(STORE_NAME, {
                        keyPath: 'id',
                        autoIncrement: true
                    });
                    
                    // Create indexes for querying
                    store.createIndex('createdAt', 'createdAt', { unique: false });
                    store.createIndex('type', 'type', { unique: false });
                    store.createIndex('status', 'status', { unique: false });
                }
            };
        });
    }

    /**
     * Handle coming online - trigger sync
     */
    async _handleOnline() {
        console.log('Network online - syncing pending actions');
        try {
            await this.syncPendingActions();
        } catch (error) {
            console.error('Auto-sync failed:', error);
        }
    }

    /**
     * Queue an action for later sync
     * 
     * @param {string} type - Action type identifier (e.g., 'rsvp', 'attendance')
     * @param {string} url - API endpoint URL
     * @param {string} method - HTTP method (POST, PUT, DELETE, PATCH)
     * @param {Object} data - Request body data
     * @param {Object} meta - Additional metadata (e.g., display info)
     * @returns {Promise<number>} ID of queued action
     */
    async queueAction(type, url, method, data, meta = {}) {
        await this.ensureInitialized();

        const action = {
            type,
            url,
            method,
            data,
            meta,
            status: 'pending',
            createdAt: new Date().toISOString(),
            attempts: 0,
            lastError: null
        };

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readwrite');
            const store = transaction.objectStore(STORE_NAME);
            const request = store.add(action);

            request.onsuccess = () => {
                console.log('Action queued:', request.result, type);
                this.dispatchQueueEvent('action-queued', { id: request.result, action });
                resolve(request.result);
            };

            request.onerror = () => {
                console.error('Failed to queue action:', request.error);
                reject(request.error);
            };
        });
    }

    /**
     * Get all pending actions
     * @returns {Promise<Array>} Array of pending actions
     */
    async getPendingActions() {
        await this.ensureInitialized();

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readonly');
            const store = transaction.objectStore(STORE_NAME);
            const index = store.index('status');
            const request = index.getAll('pending');

            request.onsuccess = () => {
                resolve(request.result || []);
            };

            request.onerror = () => {
                reject(request.error);
            };
        });
    }

    /**
     * Get all actions (including failed)
     * @returns {Promise<Array>} Array of all actions
     */
    async getAllActions() {
        await this.ensureInitialized();

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readonly');
            const store = transaction.objectStore(STORE_NAME);
            const request = store.getAll();

            request.onsuccess = () => {
                resolve(request.result || []);
            };

            request.onerror = () => {
                reject(request.error);
            };
        });
    }

    /**
     * Remove an action from the queue
     * @param {number} id - Action ID
     * @returns {Promise<void>}
     */
    async removeAction(id) {
        await this.ensureInitialized();

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readwrite');
            const store = transaction.objectStore(STORE_NAME);
            const request = store.delete(id);

            request.onsuccess = () => {
                this.dispatchQueueEvent('action-removed', { id });
                resolve();
            };

            request.onerror = () => {
                reject(request.error);
            };
        });
    }

    /**
     * Update an action with error information
     * @param {number} id - Action ID
     * @param {string} error - Error message
     * @returns {Promise<void>}
     */
    async updateActionError(id, error) {
        await this.ensureInitialized();

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readwrite');
            const store = transaction.objectStore(STORE_NAME);
            const getRequest = store.get(id);

            getRequest.onsuccess = () => {
                const action = getRequest.result;
                if (action) {
                    action.attempts += 1;
                    action.lastError = error;
                    action.lastAttempt = new Date().toISOString();
                    
                    // Mark as failed after 3 attempts
                    if (action.attempts >= 3) {
                        action.status = 'failed';
                    }

                    const updateRequest = store.put(action);
                    updateRequest.onsuccess = () => {
                        this.dispatchQueueEvent('action-error', { id, error, action });
                        resolve();
                    };
                    updateRequest.onerror = () => reject(updateRequest.error);
                } else {
                    resolve(); // Action not found, nothing to update
                }
            };

            getRequest.onerror = () => reject(getRequest.error);
        });
    }

    /**
     * Get count of pending actions
     * @returns {Promise<number>} Count of pending actions
     */
    async getPendingCount() {
        await this.ensureInitialized();

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readonly');
            const store = transaction.objectStore(STORE_NAME);
            const index = store.index('status');
            const request = index.count('pending');

            request.onsuccess = () => {
                resolve(request.result);
            };

            request.onerror = () => {
                reject(request.error);
            };
        });
    }

    /**
     * Sync all pending actions to the server
     * @param {Function} onProgress - Progress callback (current, total, action)
     * @returns {Promise<Object>} Sync result { success: number, failed: number }
     */
    async syncPendingActions(onProgress = null) {
        if (this.syncInProgress) {
            console.log('Sync already in progress');
            return { success: 0, failed: 0, skipped: true };
        }

        if (!navigator.onLine) {
            console.log('Cannot sync - offline');
            return { success: 0, failed: 0, offline: true };
        }

        this.syncInProgress = true;
        this.dispatchQueueEvent('sync-started');

        try {
            // Get fresh CSRF token
            const csrfToken = await this.refreshCsrfToken();
            
            const pending = await this.getPendingActions();
            let success = 0;
            let failed = 0;

            for (let i = 0; i < pending.length; i++) {
                const action = pending[i];
                
                if (onProgress) {
                    onProgress(i + 1, pending.length, action);
                }

                try {
                    const response = await fetch(action.url, {
                        method: action.method,
                        headers: {
                            'Content-Type': 'application/json',
                            'X-Requested-With': 'XMLHttpRequest',
                            'X-CSRF-Token': csrfToken
                        },
                        body: JSON.stringify(action.data)
                    });

                    if (response.ok) {
                        await this.removeAction(action.id);
                        success++;
                    } else {
                        const errorText = await response.text();
                        await this.updateActionError(action.id, `HTTP ${response.status}: ${errorText}`);
                        failed++;
                    }
                } catch (error) {
                    await this.updateActionError(action.id, error.message);
                    failed++;
                }
            }

            this.dispatchQueueEvent('sync-complete', { success, failed, total: pending.length });
            return { success, failed };

        } finally {
            this.syncInProgress = false;
        }
    }

    /**
     * Get fresh CSRF token from the server
     * @returns {Promise<string>} CSRF token
     */
    async refreshCsrfToken() {
        // Try to get token from meta tag first
        const metaToken = document.querySelector('meta[name="csrfToken"]');
        if (metaToken) {
            return metaToken.getAttribute('content');
        }

        // Otherwise fetch from a known endpoint that returns the token
        try {
            const response = await fetch('/api/csrf-token', {
                headers: { 'Accept': 'application/json' }
            });
            if (response.ok) {
                const data = await response.json();
                return data.token;
            }
        } catch (error) {
            console.warn('Could not refresh CSRF token:', error);
        }

        return '';
    }

    /**
     * Clear all actions from the queue
     * @returns {Promise<void>}
     */
    async clearAll() {
        await this.ensureInitialized();

        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([STORE_NAME], 'readwrite');
            const store = transaction.objectStore(STORE_NAME);
            const request = store.clear();

            request.onsuccess = () => {
                this.dispatchQueueEvent('queue-cleared');
                resolve();
            };

            request.onerror = () => {
                reject(request.error);
            };
        });
    }

    /**
     * Ensure the database is initialized
     */
    async ensureInitialized() {
        if (!this.isInitialized) {
            await this.init();
        }
    }

    /**
     * Dispatch a custom event for queue state changes
     * @param {string} eventName - Event name
     * @param {Object} detail - Event detail
     */
    dispatchQueueEvent(eventName, detail = {}) {
        const event = new CustomEvent(`offline-queue:${eventName}`, {
            bubbles: true,
            detail
        });
        window.dispatchEvent(event);
    }

    /**
     * Destroy the service and clean up
     */
    destroy() {
        window.removeEventListener('online', this._handleOnline);
        if (this.db) {
            this.db.close();
            this.db = null;
        }
        this.isInitialized = false;
    }
}

// Create singleton instance
const offlineQueueService = new OfflineQueueService();

// Export for use in other modules
export default offlineQueueService;

// Also expose globally for non-module scripts
window.OfflineQueueService = offlineQueueService;