From 648cf2c5a53f31dc787d1aed64438b2e67176928 Mon Sep 17 00:00:00 2001 From: Joerg Date: Thu, 22 Jan 2026 12:40:55 +0100 Subject: [PATCH] feat: implement auto-sync scheduler for LoTW and DCL Add automatic synchronization scheduler that allows users to configure periodic sync intervals for LoTW and DCL via the settings page. Features: - Users can enable/disable auto-sync per service (LoTW/DCL) - Configurable sync intervals (1-720 hours) - Settings page UI for managing auto-sync preferences - Dashboard shows upcoming scheduled auto-sync jobs - Scheduler runs every minute, triggers syncs when due - Survives server restarts via database persistence - Graceful shutdown support (SIGINT/SIGTERM) Backend: - New autoSyncSettings table with user preferences - auto-sync.service.js for CRUD operations and scheduling logic - scheduler.service.js for periodic tick processing - API endpoints: GET/PUT /auto-sync/settings, GET /auto-sync/scheduler/status Frontend: - Auto-sync settings section in settings page - Upcoming auto-sync section on dashboard with scheduled job cards - Purple-themed UI for scheduled jobs with countdown animation Co-Authored-By: Claude --- src/backend/db/schema/index.js | 36 +- src/backend/index.js | 155 ++++++++ .../migrations/add-auto-sync-settings.js | 111 ++++++ src/backend/services/auto-sync.service.js | 340 ++++++++++++++++++ src/backend/services/scheduler.service.js | 234 ++++++++++++ src/frontend/src/lib/api.js | 12 + src/frontend/src/routes/+page.svelte | 199 +++++++++- src/frontend/src/routes/settings/+page.svelte | 229 +++++++++++- 8 files changed, 1313 insertions(+), 3 deletions(-) create mode 100644 src/backend/migrations/add-auto-sync-settings.js create mode 100644 src/backend/services/auto-sync.service.js create mode 100644 src/backend/services/scheduler.service.js diff --git a/src/backend/db/schema/index.js b/src/backend/db/schema/index.js index 95601bd..670bcfd 100644 --- a/src/backend/db/schema/index.js +++ b/src/backend/db/schema/index.js @@ -223,5 +223,39 @@ export const adminActions = sqliteTable('admin_actions', { createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }); +/** + * @typedef {Object} AutoSyncSettings + * @property {number} userId + * @property {boolean} lotwEnabled + * @property {number} lotwIntervalHours + * @property {Date|null} lotwLastSyncAt + * @property {Date|null} lotwNextSyncAt + * @property {boolean} dclEnabled + * @property {number} dclIntervalHours + * @property {Date|null} dclLastSyncAt + * @property {Date|null} dclNextSyncAt + * @property {Date} createdAt + * @property {Date} updatedAt + */ + +export const autoSyncSettings = sqliteTable('auto_sync_settings', { + userId: integer('user_id').primaryKey().references(() => users.id), + + // LoTW auto-sync settings + lotwEnabled: integer('lotw_enabled', { mode: 'boolean' }).notNull().default(false), + lotwIntervalHours: integer('lotw_interval_hours').notNull().default(24), + lotwLastSyncAt: integer('lotw_last_sync_at', { mode: 'timestamp' }), + lotwNextSyncAt: integer('lotw_next_sync_at', { mode: 'timestamp' }), + + // DCL auto-sync settings + dclEnabled: integer('dcl_enabled', { mode: 'boolean' }).notNull().default(false), + dclIntervalHours: integer('dcl_interval_hours').notNull().default(24), + dclLastSyncAt: integer('dcl_last_sync_at', { mode: 'timestamp' }), + dclNextSyncAt: integer('dcl_next_sync_at', { mode: 'timestamp' }), + + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), +}); + // Export all schemas -export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions }; +export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings }; diff --git a/src/backend/index.js b/src/backend/index.js index bfa4619..b0804e8 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -43,6 +43,16 @@ import { getAwardProgressDetails, getAwardEntityBreakdown, } from './services/awards.service.js'; +import { + getAutoSyncSettings, + updateAutoSyncSettings, +} from './services/auto-sync.service.js'; +import { + startScheduler, + stopScheduler, + getSchedulerStatus, + triggerSchedulerTick, +} from './services/scheduler.service.js'; /** * Main backend application @@ -1398,6 +1408,133 @@ const app = new Elysia() } }) + /** + * ================================================================ + * AUTO-SYNC SETTINGS ROUTES + * ================================================================ + * All auto-sync routes require authentication + */ + + /** + * GET /api/auto-sync/settings + * Get user's auto-sync settings (requires authentication) + */ + .get('/api/auto-sync/settings', async ({ user, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const settings = await getAutoSyncSettings(user.id); + + return { + success: true, + settings, + }; + } catch (error) { + logger.error('Error fetching auto-sync settings', { error: error.message, userId: user.id }); + set.status = 500; + return { + success: false, + error: 'Failed to fetch auto-sync settings', + }; + } + }) + + /** + * PUT /api/auto-sync/settings + * Update user's auto-sync settings (requires authentication) + */ + .put( + '/api/auto-sync/settings', + async ({ user, body, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const settings = await updateAutoSyncSettings(user.id, body); + + return { + success: true, + settings, + message: 'Auto-sync settings updated successfully', + }; + } catch (error) { + logger.error('Error updating auto-sync settings', { error: error.message, userId: user.id }); + set.status = 400; + return { + success: false, + error: error.message, + }; + } + }, + { + body: t.Object({ + lotwEnabled: t.Optional(t.Boolean()), + lotwIntervalHours: t.Optional(t.Number()), + dclEnabled: t.Optional(t.Boolean()), + dclIntervalHours: t.Optional(t.Number()), + }), + } + ) + + /** + * GET /api/auto-sync/scheduler/status + * Get scheduler status (admin only) + */ + .get('/api/auto-sync/scheduler/status', async ({ user, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + const status = getSchedulerStatus(); + + return { + success: true, + scheduler: status, + }; + } catch (error) { + logger.error('Error fetching scheduler status', { error: error.message, userId: user.id }); + set.status = 500; + return { + success: false, + error: 'Failed to fetch scheduler status', + }; + } + }) + + /** + * POST /api/auto-sync/scheduler/trigger + * Manually trigger scheduler tick (admin only, for testing) + */ + .post('/api/auto-sync/scheduler/trigger', async ({ user, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + await triggerSchedulerTick(); + + return { + success: true, + message: 'Scheduler tick triggered successfully', + }; + } catch (error) { + logger.error('Error triggering scheduler tick', { error: error.message, userId: user.id }); + set.status = 500; + return { + success: false, + error: 'Failed to trigger scheduler tick', + }; + } + }) + // Serve static files and SPA fallback for all non-API routes .get('/*', ({ request }) => { const url = new URL(request.url); @@ -1554,3 +1691,21 @@ logger.info('Server started', { nodeEnv: process.env.NODE_ENV || 'unknown', logLevel: LOG_LEVEL, }); + +// Start the auto-sync scheduler +startScheduler(); + +// Graceful shutdown handlers +const gracefulShutdown = async (signal) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + + // Stop the scheduler + await stopScheduler(); + + logger.info('Graceful shutdown complete'); + process.exit(0); +}; + +// Handle shutdown signals +process.on('SIGINT', () => gracefulShutdown('SIGINT')); +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); diff --git a/src/backend/migrations/add-auto-sync-settings.js b/src/backend/migrations/add-auto-sync-settings.js new file mode 100644 index 0000000..6d17187 --- /dev/null +++ b/src/backend/migrations/add-auto-sync-settings.js @@ -0,0 +1,111 @@ +/** + * Migration: Add auto_sync_settings table + * + * This script creates the auto_sync_settings table for managing + * automatic sync intervals for DCL and LoTW services. + * Users can enable/disable auto-sync and configure sync intervals. + */ + +import Database from 'bun:sqlite'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const dbPath = join(__dirname, '../award.db'); +const sqlite = new Database(dbPath); + +async function migrate() { + console.log('Starting migration: Add auto-sync settings...'); + + try { + // Check if auto_sync_settings table already exists + const tableExists = sqlite.query(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='auto_sync_settings' + `).get(); + + if (tableExists) { + console.log('Table auto_sync_settings already exists. Skipping...'); + } else { + // Create auto_sync_settings table + sqlite.exec(` + CREATE TABLE auto_sync_settings ( + user_id INTEGER PRIMARY KEY, + lotw_enabled INTEGER NOT NULL DEFAULT 0, + lotw_interval_hours INTEGER NOT NULL DEFAULT 24, + lotw_last_sync_at INTEGER, + lotw_next_sync_at INTEGER, + dcl_enabled INTEGER NOT NULL DEFAULT 0, + dcl_interval_hours INTEGER NOT NULL DEFAULT 24, + dcl_last_sync_at INTEGER, + dcl_next_sync_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // Create index for faster queries on next_sync_at + sqlite.exec(` + CREATE INDEX idx_auto_sync_settings_lotw_next_sync_at + ON auto_sync_settings(lotw_next_sync_at) + WHERE lotw_enabled = 1 + `); + + sqlite.exec(` + CREATE INDEX idx_auto_sync_settings_dcl_next_sync_at + ON auto_sync_settings(dcl_next_sync_at) + WHERE dcl_enabled = 1 + `); + + console.log('Created auto_sync_settings table with indexes'); + } + + console.log('Migration complete! Auto-sync settings table added to database.'); + } catch (error) { + console.error('Migration failed:', error); + sqlite.close(); + process.exit(1); + } + + sqlite.close(); +} + +async function rollback() { + console.log('Starting rollback: Remove auto-sync settings...'); + + try { + // Drop indexes first + sqlite.exec(`DROP INDEX IF EXISTS idx_auto_sync_settings_lotw_next_sync_at`); + sqlite.exec(`DROP INDEX IF EXISTS idx_auto_sync_settings_dcl_next_sync_at`); + + // Drop table + sqlite.exec(`DROP TABLE IF EXISTS auto_sync_settings`); + + console.log('Rollback complete! Auto-sync settings table removed from database.'); + } catch (error) { + console.error('Rollback failed:', error); + sqlite.close(); + process.exit(1); + } + + sqlite.close(); +} + +// Check if this is a rollback +const args = process.argv.slice(2); +if (args.includes('--rollback') || args.includes('-r')) { + rollback().then(() => { + console.log('Rollback script completed successfully'); + process.exit(0); + }); +} else { + // Run migration + migrate().then(() => { + console.log('Migration script completed successfully'); + process.exit(0); + }); +} diff --git a/src/backend/services/auto-sync.service.js b/src/backend/services/auto-sync.service.js new file mode 100644 index 0000000..f703b71 --- /dev/null +++ b/src/backend/services/auto-sync.service.js @@ -0,0 +1,340 @@ +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 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) + ) + ); + + // Filter out users without credentials + const withCredentials = results.filter(r => r.hasCredentials); + + logger.debug('Found pending sync users', { + service, + total: results.length, + withCredentials: withCredentials.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; + } +} diff --git a/src/backend/services/scheduler.service.js b/src/backend/services/scheduler.service.js new file mode 100644 index 0000000..ebb8e1d --- /dev/null +++ b/src/backend/services/scheduler.service.js @@ -0,0 +1,234 @@ +import { logger } from '../config.js'; +import { + getPendingSyncUsers, + updateSyncTimestamps, +} from './auto-sync.service.js'; +import { + enqueueJob, + getUserActiveJob, +} from './job-queue.service.js'; +import { getUserById } from './auth.service.js'; + +/** + * Auto-Sync Scheduler Service + * Manages automatic synchronization of DCL and LoTW data + * Runs every minute to check for due syncs and enqueues jobs + */ + +// Scheduler state +let schedulerInterval = null; +let isRunning = false; +let isShuttingDown = false; + +// Scheduler configuration +const SCHEDULER_TICK_INTERVAL_MS = 60 * 1000; // 1 minute +const INITIAL_DELAY_MS = 5000; // 5 seconds after server start + +// Allow faster tick interval for testing (set via environment variable) +const TEST_MODE = process.env.SCHEDULER_TEST_MODE === 'true'; +const TEST_TICK_INTERVAL_MS = 10 * 1000; // 10 seconds in test mode + +/** + * Get scheduler status + * @returns {Object} Scheduler status + */ +export function getSchedulerStatus() { + return { + isRunning, + isShuttingDown, + tickIntervalMs: TEST_MODE ? TEST_TICK_INTERVAL_MS : SCHEDULER_TICK_INTERVAL_MS, + activeInterval: !!schedulerInterval, + testMode: TEST_MODE, + }; +} + +/** + * Process pending syncs for a specific service + * @param {string} service - 'lotw' or 'dcl' + */ +async function processServiceSyncs(service) { + try { + const pendingUsers = await getPendingSyncUsers(service); + + if (pendingUsers.length === 0) { + logger.debug('No pending syncs', { service }); + return; + } + + logger.info('Processing pending syncs', { + service, + count: pendingUsers.length, + }); + + for (const user of pendingUsers) { + if (isShuttingDown) { + logger.info('Scheduler shutting down, skipping pending sync', { + service, + userId: user.userId, + }); + break; + } + + try { + // Check if there's already an active job for this user and service + const activeJob = await getUserActiveJob(user.userId, `${service}_sync`); + + if (activeJob) { + logger.debug('User already has active job, skipping', { + service, + userId: user.userId, + activeJobId: activeJob.id, + }); + + // Update the next sync time to try again later + // This prevents continuous checking while a job is running + await updateSyncTimestamps(user.userId, service, new Date()); + continue; + } + + // Enqueue the sync job + logger.info('Enqueuing auto-sync job', { + service, + userId: user.userId, + }); + + const result = await enqueueJob(user.userId, `${service}_sync`); + + if (result.success) { + // Update timestamps immediately on successful enqueue + await updateSyncTimestamps(user.userId, service, new Date()); + } else { + logger.warn('Failed to enqueue auto-sync job', { + service, + userId: user.userId, + reason: result.error, + }); + } + } catch (error) { + logger.error('Error processing user sync', { + service, + userId: user.userId, + error: error.message, + }); + } + } + } catch (error) { + logger.error('Error processing service syncs', { + service, + error: error.message, + }); + } +} + +/** + * Main scheduler tick function + * Checks for pending LoTW and DCL syncs and processes them + */ +async function schedulerTick() { + if (isShuttingDown) { + logger.debug('Scheduler shutdown in progress, skipping tick'); + return; + } + + try { + logger.debug('Scheduler tick started'); + + // Process LoTW syncs + await processServiceSyncs('lotw'); + + // Process DCL syncs + await processServiceSyncs('dcl'); + + logger.debug('Scheduler tick completed'); + } catch (error) { + logger.error('Scheduler tick error', { + error: error.message, + stack: error.stack, + }); + } +} + +/** + * Start the scheduler + * Begins periodic checks for pending syncs + */ +export function startScheduler() { + if (isRunning) { + logger.warn('Scheduler already running'); + return; + } + + // Check if scheduler is disabled via environment variable + if (process.env.DISABLE_SCHEDULER === 'true') { + logger.info('Scheduler disabled via DISABLE_SCHEDULER environment variable'); + return; + } + + isRunning = true; + isShuttingDown = false; + + const tickInterval = TEST_MODE ? TEST_TICK_INTERVAL_MS : SCHEDULER_TICK_INTERVAL_MS; + + // Initial delay to allow server to fully start + logger.info('Scheduler starting, initial tick in 5 seconds', { + testMode: TEST_MODE, + tickIntervalMs: tickInterval, + }); + + // Schedule first tick + setTimeout(() => { + if (!isShuttingDown) { + schedulerTick(); + + // Set up recurring interval + schedulerInterval = setInterval(() => { + if (!isShuttingDown) { + schedulerTick(); + } + }, tickInterval); + + logger.info('Scheduler started', { + tickIntervalMs: tickInterval, + testMode: TEST_MODE, + }); + } + }, INITIAL_DELAY_MS); +} + +/** + * Stop the scheduler gracefully + * Waits for current tick to complete before stopping + */ +export async function stopScheduler() { + if (!isRunning) { + logger.debug('Scheduler not running'); + return; + } + + logger.info('Stopping scheduler...'); + isShuttingDown = true; + + // Clear the interval + if (schedulerInterval) { + clearInterval(schedulerInterval); + schedulerInterval = null; + } + + // Wait a moment for any in-progress tick to complete + await new Promise(resolve => setTimeout(resolve, 100)); + + isRunning = false; + logger.info('Scheduler stopped'); +} + +/** + * Trigger an immediate scheduler tick (for testing or manual sync) + */ +export async function triggerSchedulerTick() { + if (!isRunning) { + throw new Error('Scheduler is not running'); + } + + logger.info('Manual scheduler tick triggered'); + await schedulerTick(); +} diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index 1ef1e84..2464fed 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -118,3 +118,15 @@ export const adminAPI = { getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`), }; + +// Auto-Sync API +export const autoSyncAPI = { + getSettings: () => apiRequest('/auto-sync/settings'), + + updateSettings: (settings) => apiRequest('/auto-sync/settings', { + method: 'PUT', + body: JSON.stringify(settings), + }), + + getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'), +}; diff --git a/src/frontend/src/routes/+page.svelte b/src/frontend/src/routes/+page.svelte index 43cbd39..f099d1f 100644 --- a/src/frontend/src/routes/+page.svelte +++ b/src/frontend/src/routes/+page.svelte @@ -1,7 +1,7 @@