import { db, logger } from '../config.js'; import { autoSyncSettings, users } from '../db/schema/index.js'; import { eq, and, lte, or } from 'drizzle-orm'; /** * Auto-Sync Settings Service * Manages user preferences for automatic DCL and LoTW synchronization */ // Validation constants export const MIN_INTERVAL_HOURS = 1; export const MAX_INTERVAL_HOURS = 720; // 30 days export const DEFAULT_INTERVAL_HOURS = 24; /** * Get auto-sync settings for a user * Creates default settings if they don't exist * * @param {number} userId - User ID * @returns {Promise} Auto-sync settings */ export async function getAutoSyncSettings(userId) { try { let [settings] = await db .select() .from(autoSyncSettings) .where(eq(autoSyncSettings.userId, userId)); // Create default settings if they don't exist if (!settings) { logger.debug('Creating default auto-sync settings for user', { userId }); [settings] = await db .insert(autoSyncSettings) .values({ userId, lotwEnabled: false, lotwIntervalHours: DEFAULT_INTERVAL_HOURS, dclEnabled: false, dclIntervalHours: DEFAULT_INTERVAL_HOURS, }) .returning(); } return { lotwEnabled: settings.lotwEnabled, lotwIntervalHours: settings.lotwIntervalHours, lotwLastSyncAt: settings.lotwLastSyncAt, lotwNextSyncAt: settings.lotwNextSyncAt, dclEnabled: settings.dclEnabled, dclIntervalHours: settings.dclIntervalHours, dclLastSyncAt: settings.dclLastSyncAt, dclNextSyncAt: settings.dclNextSyncAt, }; } catch (error) { logger.error('Failed to get auto-sync settings', { error: error.message, userId }); throw error; } } /** * Validate interval hours * @param {number} hours - Interval hours to validate * @returns {Object} Validation result */ function validateIntervalHours(hours) { if (typeof hours !== 'number' || isNaN(hours)) { return { valid: false, error: 'Interval must be a number' }; } if (!Number.isInteger(hours)) { return { valid: false, error: 'Interval must be a whole number of hours' }; } if (hours < MIN_INTERVAL_HOURS) { return { valid: false, error: `Interval must be at least ${MIN_INTERVAL_HOURS} hour` }; } if (hours > MAX_INTERVAL_HOURS) { return { valid: false, error: `Interval must be at most ${MAX_INTERVAL_HOURS} hours (30 days)` }; } return { valid: true }; } /** * Calculate next sync time based on interval * @param {number} intervalHours - Interval in hours * @returns {Date} Next sync time */ function calculateNextSyncTime(intervalHours) { const nextSync = new Date(); nextSync.setHours(nextSync.getHours() + intervalHours); return nextSync; } /** * Update auto-sync settings for a user * * @param {number} userId - User ID * @param {Object} settings - Settings to update * @returns {Promise} Updated settings */ export async function updateAutoSyncSettings(userId, settings) { try { // Get current settings let [currentSettings] = await db .select() .from(autoSyncSettings) .where(eq(autoSyncSettings.userId, userId)); // Create default settings if they don't exist if (!currentSettings) { [currentSettings] = await db .insert(autoSyncSettings) .values({ userId, lotwEnabled: false, lotwIntervalHours: DEFAULT_INTERVAL_HOURS, dclEnabled: false, dclIntervalHours: DEFAULT_INTERVAL_HOURS, }) .returning(); } // Prepare update data const updateData = { updatedAt: new Date(), }; // Validate and update LoTW settings if (settings.lotwEnabled !== undefined) { if (typeof settings.lotwEnabled !== 'boolean') { throw new Error('lotwEnabled must be a boolean'); } updateData.lotwEnabled = settings.lotwEnabled; // If enabling for the first time or interval changed, set next sync time if (settings.lotwEnabled && (!currentSettings.lotwEnabled || settings.lotwIntervalHours)) { const intervalHours = settings.lotwIntervalHours || currentSettings.lotwIntervalHours; const validation = validateIntervalHours(intervalHours); if (!validation.valid) { throw new Error(`LoTW interval: ${validation.error}`); } updateData.lotwNextSyncAt = calculateNextSyncTime(intervalHours); } else if (!settings.lotwEnabled) { // Clear next sync when disabling updateData.lotwNextSyncAt = null; } } if (settings.lotwIntervalHours !== undefined) { const validation = validateIntervalHours(settings.lotwIntervalHours); if (!validation.valid) { throw new Error(`LoTW interval: ${validation.error}`); } updateData.lotwIntervalHours = settings.lotwIntervalHours; // Update next sync time if LoTW is enabled if (currentSettings.lotwEnabled || settings.lotwEnabled) { updateData.lotwNextSyncAt = calculateNextSyncTime(settings.lotwIntervalHours); } } // Validate and update DCL settings if (settings.dclEnabled !== undefined) { if (typeof settings.dclEnabled !== 'boolean') { throw new Error('dclEnabled must be a boolean'); } updateData.dclEnabled = settings.dclEnabled; // If enabling for the first time or interval changed, set next sync time if (settings.dclEnabled && (!currentSettings.dclEnabled || settings.dclIntervalHours)) { const intervalHours = settings.dclIntervalHours || currentSettings.dclIntervalHours; const validation = validateIntervalHours(intervalHours); if (!validation.valid) { throw new Error(`DCL interval: ${validation.error}`); } updateData.dclNextSyncAt = calculateNextSyncTime(intervalHours); } else if (!settings.dclEnabled) { // Clear next sync when disabling updateData.dclNextSyncAt = null; } } if (settings.dclIntervalHours !== undefined) { const validation = validateIntervalHours(settings.dclIntervalHours); if (!validation.valid) { throw new Error(`DCL interval: ${validation.error}`); } updateData.dclIntervalHours = settings.dclIntervalHours; // Update next sync time if DCL is enabled if (currentSettings.dclEnabled || settings.dclEnabled) { updateData.dclNextSyncAt = calculateNextSyncTime(settings.dclIntervalHours); } } // Update settings in database const [updated] = await db .update(autoSyncSettings) .set(updateData) .where(eq(autoSyncSettings.userId, userId)) .returning(); logger.info('Updated auto-sync settings', { userId, lotwEnabled: updated.lotwEnabled, lotwIntervalHours: updated.lotwIntervalHours, dclEnabled: updated.dclEnabled, dclIntervalHours: updated.dclIntervalHours, }); return { lotwEnabled: updated.lotwEnabled, lotwIntervalHours: updated.lotwIntervalHours, lotwLastSyncAt: updated.lotwLastSyncAt, lotwNextSyncAt: updated.lotwNextSyncAt, dclEnabled: updated.dclEnabled, dclIntervalHours: updated.dclIntervalHours, dclLastSyncAt: updated.dclLastSyncAt, dclNextSyncAt: updated.dclNextSyncAt, }; } catch (error) { logger.error('Failed to update auto-sync settings', { error: error.message, userId }); throw error; } } /** * Get users with pending syncs for a specific service * * @param {string} service - 'lotw' or 'dcl' * @returns {Promise} List of users with pending syncs */ export async function getPendingSyncUsers(service) { try { if (service !== 'lotw' && service !== 'dcl') { throw new Error('Service must be "lotw" or "dcl"'); } const enabledField = service === 'lotw' ? autoSyncSettings.lotwEnabled : autoSyncSettings.dclEnabled; const nextSyncField = service === 'lotw' ? autoSyncSettings.lotwNextSyncAt : autoSyncSettings.dclNextSyncAt; const credentialField = service === 'lotw' ? users.lotwUsername : users.dclApiKey; const intervalField = service === 'lotw' ? autoSyncSettings.lotwIntervalHours : autoSyncSettings.dclIntervalHours; const now = new Date(); // Get users with auto-sync enabled and next sync time in the past const results = await db .select({ userId: autoSyncSettings.userId, lotwEnabled: autoSyncSettings.lotwEnabled, lotwIntervalHours: autoSyncSettings.lotwIntervalHours, lotwNextSyncAt: autoSyncSettings.lotwNextSyncAt, dclEnabled: autoSyncSettings.dclEnabled, dclIntervalHours: autoSyncSettings.dclIntervalHours, dclNextSyncAt: autoSyncSettings.dclNextSyncAt, hasCredentials: credentialField, // Just check if field exists (not null/empty) }) .from(autoSyncSettings) .innerJoin(users, eq(autoSyncSettings.userId, users.id)) .where( and( eq(enabledField, true), lte(nextSyncField, now) ) ); // Split into users with and without credentials const withCredentials = results.filter(r => r.hasCredentials); const withoutCredentials = results.filter(r => !r.hasCredentials); // For users without credentials, update their next sync time to retry in 24 hours // This prevents them from being continuously retried every minute if (withoutCredentials.length > 0) { const retryDate = new Date(); retryDate.setHours(retryDate.getHours() + 24); for (const user of withoutCredentials) { const updateData = { updatedAt: new Date(), }; if (service === 'lotw') { updateData.lotwNextSyncAt = retryDate; } else { updateData.dclNextSyncAt = retryDate; } await db .update(autoSyncSettings) .set(updateData) .where(eq(autoSyncSettings.userId, user.userId)); } logger.warn('Skipped auto-sync for users without credentials, will retry in 24 hours', { service, count: withoutCredentials.length, userIds: withoutCredentials.map(u => u.userId), }); } logger.debug('Found pending sync users', { service, total: results.length, withCredentials: withCredentials.length, withoutCredentials: withoutCredentials.length, }); return withCredentials; } catch (error) { logger.error('Failed to get pending sync users', { error: error.message, service }); return []; } } /** * Update sync timestamps after a successful sync * * @param {number} userId - User ID * @param {string} service - 'lotw' or 'dcl' * @param {Date} lastSyncDate - Date of last sync * @returns {Promise} */ export async function updateSyncTimestamps(userId, service, lastSyncDate) { try { if (service !== 'lotw' && service !== 'dcl') { throw new Error('Service must be "lotw" or "dcl"'); } // Get current settings to find the interval const [currentSettings] = await db .select() .from(autoSyncSettings) .where(eq(autoSyncSettings.userId, userId)); if (!currentSettings) { logger.warn('No auto-sync settings found for user', { userId, service }); return; } const intervalHours = service === 'lotw' ? currentSettings.lotwIntervalHours : currentSettings.dclIntervalHours; // Calculate next sync time const nextSyncAt = calculateNextSyncTime(intervalHours); // Update timestamps const updateData = { updatedAt: new Date(), }; if (service === 'lotw') { updateData.lotwLastSyncAt = lastSyncDate; updateData.lotwNextSyncAt = nextSyncAt; } else { updateData.dclLastSyncAt = lastSyncDate; updateData.dclNextSyncAt = nextSyncAt; } await db .update(autoSyncSettings) .set(updateData) .where(eq(autoSyncSettings.userId, userId)); logger.debug('Updated sync timestamps', { userId, service, lastSyncAt: lastSyncDate.toISOString(), nextSyncAt: nextSyncAt.toISOString(), }); } catch (error) { logger.error('Failed to update sync timestamps', { error: error.message, userId, service }); throw error; } }