Compare commits
2 Commits
cce520a00e
...
69b33720b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
69b33720b3
|
|||
|
648cf2c5a5
|
@@ -223,5 +223,39 @@ export const adminActions = sqliteTable('admin_actions', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
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 all schemas
|
||||||
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions };
|
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ import {
|
|||||||
getAwardProgressDetails,
|
getAwardProgressDetails,
|
||||||
getAwardEntityBreakdown,
|
getAwardEntityBreakdown,
|
||||||
} from './services/awards.service.js';
|
} 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
|
* 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
|
// Serve static files and SPA fallback for all non-API routes
|
||||||
.get('/*', ({ request }) => {
|
.get('/*', ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -1554,3 +1691,21 @@ logger.info('Server started', {
|
|||||||
nodeEnv: process.env.NODE_ENV || 'unknown',
|
nodeEnv: process.env.NODE_ENV || 'unknown',
|
||||||
logLevel: LOG_LEVEL,
|
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'));
|
||||||
|
|||||||
111
src/backend/migrations/add-auto-sync-settings.js
Normal file
111
src/backend/migrations/add-auto-sync-settings.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
373
src/backend/services/auto-sync.service.js
Normal file
373
src/backend/services/auto-sync.service.js
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
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<Object>} 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<Object>} 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<Array>} 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<void>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/backend/services/scheduler.service.js
Normal file
234
src/backend/services/scheduler.service.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -118,3 +118,15 @@ export const adminAPI = {
|
|||||||
|
|
||||||
getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`),
|
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'),
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { jobsAPI } from '$lib/api.js';
|
import { jobsAPI, autoSyncAPI } from '$lib/api.js';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let jobs = [];
|
let jobs = [];
|
||||||
@@ -9,6 +9,18 @@
|
|||||||
let cancellingJobs = new Map(); // Track cancelling state per job
|
let cancellingJobs = new Map(); // Track cancelling state per job
|
||||||
let pollingInterval = null;
|
let pollingInterval = null;
|
||||||
|
|
||||||
|
// Auto-sync settings state
|
||||||
|
let autoSyncSettings = null;
|
||||||
|
let loadingAutoSync = false;
|
||||||
|
|
||||||
|
// Reactive: scheduled jobs derived from settings
|
||||||
|
// Note: Explicitly reference autoSyncSettings to ensure Svelte tracks it as a dependency
|
||||||
|
let scheduledJobs = [];
|
||||||
|
$: {
|
||||||
|
autoSyncSettings; // Touch variable so Svelte tracks reactivity
|
||||||
|
scheduledJobs = getScheduledJobs();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadJobs() {
|
async function loadJobs() {
|
||||||
try {
|
try {
|
||||||
const response = await jobsAPI.getRecent(5);
|
const response = await jobsAPI.getRecent(5);
|
||||||
@@ -22,6 +34,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAutoSyncSettings() {
|
||||||
|
try {
|
||||||
|
loadingAutoSync = true;
|
||||||
|
const response = await autoSyncAPI.getSettings();
|
||||||
|
autoSyncSettings = response.settings || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load auto-sync settings:', error);
|
||||||
|
// Don't show error, auto-sync is optional
|
||||||
|
} finally {
|
||||||
|
loadingAutoSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduledJobs() {
|
||||||
|
if (!autoSyncSettings) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduled = [];
|
||||||
|
|
||||||
|
if (autoSyncSettings.lotwEnabled) {
|
||||||
|
scheduled.push({
|
||||||
|
type: 'lotw_sync',
|
||||||
|
icon: '📡',
|
||||||
|
name: 'LoTW Auto-Sync',
|
||||||
|
interval: autoSyncSettings.lotwIntervalHours,
|
||||||
|
nextSyncAt: autoSyncSettings.lotwNextSyncAt,
|
||||||
|
lastSyncAt: autoSyncSettings.lotwLastSyncAt,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSyncSettings.dclEnabled) {
|
||||||
|
scheduled.push({
|
||||||
|
type: 'dcl_sync',
|
||||||
|
icon: '🛰️',
|
||||||
|
name: 'DCL Auto-Sync',
|
||||||
|
interval: autoSyncSettings.dclIntervalHours,
|
||||||
|
nextSyncAt: autoSyncSettings.dclNextSyncAt,
|
||||||
|
lastSyncAt: autoSyncSettings.dclLastSyncAt,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextSyncLabel(nextSyncAt, interval) {
|
||||||
|
if (!nextSyncAt) return 'Pending...';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextSync = new Date(nextSyncAt);
|
||||||
|
const diffMs = nextSync - now;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMs < 0) return 'Due now';
|
||||||
|
if (diffMins < 60) return `In ${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
||||||
|
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
||||||
|
return `In ${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextSyncTime(nextSyncAt) {
|
||||||
|
if (!nextSyncAt) return null;
|
||||||
|
const date = new Date(nextSyncAt);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSyncTime(lastSyncAt) {
|
||||||
|
if (!lastSyncAt) return 'Never';
|
||||||
|
const date = new Date(lastSyncAt);
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
function hasActiveJobs() {
|
function hasActiveJobs() {
|
||||||
return jobs.some(job => job.status === 'pending' || job.status === 'running');
|
return jobs.some(job => job.status === 'pending' || job.status === 'running');
|
||||||
}
|
}
|
||||||
@@ -58,6 +145,7 @@
|
|||||||
// Load recent jobs if authenticated
|
// Load recent jobs if authenticated
|
||||||
if ($auth.user) {
|
if ($auth.user) {
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
|
await loadAutoSyncSettings();
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -187,6 +275,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Auto-Sync Jobs -->
|
||||||
|
{#if scheduledJobs.length > 0}
|
||||||
|
<div class="scheduled-section">
|
||||||
|
<h2 class="section-title">⏰ Upcoming Auto-Sync</h2>
|
||||||
|
<div class="jobs-list">
|
||||||
|
{#each scheduledJobs as scheduled (scheduled.type)}
|
||||||
|
<div class="job-card job-card-scheduled">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">
|
||||||
|
<span class="job-icon">{scheduled.icon}</span>
|
||||||
|
<span class="job-name">{scheduled.name}</span>
|
||||||
|
<span class="job-badge scheduled-badge">Scheduled</span>
|
||||||
|
</div>
|
||||||
|
<span class="scheduled-interval">Every {scheduled.interval}h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-meta">
|
||||||
|
<span class="job-date">
|
||||||
|
Next: <strong title={formatNextSyncTime(scheduled.nextSyncAt)}>
|
||||||
|
{getNextSyncLabel(scheduled.nextSyncAt, scheduled.interval)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span class="job-time">
|
||||||
|
Last: {formatLastSyncTime(scheduled.lastSyncAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scheduled-countdown">
|
||||||
|
<div class="countdown-bar">
|
||||||
|
<div class="countdown-progress"></div>
|
||||||
|
</div>
|
||||||
|
<p class="countdown-text">
|
||||||
|
{formatNextSyncTime(scheduled.nextSyncAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Recent Sync Jobs -->
|
<!-- Recent Sync Jobs -->
|
||||||
<div class="jobs-section">
|
<div class="jobs-section">
|
||||||
<h2 class="section-title">🔄 Recent Sync Jobs</h2>
|
<h2 class="section-title">🔄 Recent Sync Jobs</h2>
|
||||||
@@ -484,6 +613,11 @@
|
|||||||
border-left: 4px solid #dc3545;
|
border-left: 4px solid #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-card-scheduled {
|
||||||
|
border-left: 4px solid #8b5cf6;
|
||||||
|
background: linear-gradient(to right, #f8f7ff, white);
|
||||||
|
}
|
||||||
|
|
||||||
.job-header {
|
.job-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -561,6 +695,20 @@
|
|||||||
color: #6b21a8;
|
color: #6b21a8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-badge {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-badge {
|
||||||
|
background-color: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.job-meta {
|
.job-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -659,4 +807,53 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scheduled Jobs Section */
|
||||||
|
.scheduled-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scheduled job countdown */
|
||||||
|
.scheduled-countdown {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-progress {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 100%;
|
||||||
|
animation: pulse-countdown 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-countdown {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.scheduled-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { authAPI } from '$lib/api.js';
|
import { authAPI, autoSyncAPI } from '$lib/api.js';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
@@ -17,9 +17,23 @@
|
|||||||
let hasLoTWCredentials = false;
|
let hasLoTWCredentials = false;
|
||||||
let hasDCLCredentials = false;
|
let hasDCLCredentials = false;
|
||||||
|
|
||||||
|
// Auto-sync settings
|
||||||
|
let autoSyncSettings = {
|
||||||
|
lotwEnabled: false,
|
||||||
|
lotwIntervalHours: 24,
|
||||||
|
lotwNextSyncAt: null,
|
||||||
|
dclEnabled: false,
|
||||||
|
dclIntervalHours: 24,
|
||||||
|
dclNextSyncAt: null,
|
||||||
|
};
|
||||||
|
let loadingAutoSync = false;
|
||||||
|
let savingAutoSync = false;
|
||||||
|
let successAutoSync = false;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Load user profile to check if credentials exist
|
// Load user profile to check if credentials exist
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
|
await loadAutoSyncSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
@@ -41,6 +55,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAutoSyncSettings() {
|
||||||
|
try {
|
||||||
|
loadingAutoSync = true;
|
||||||
|
const response = await autoSyncAPI.getSettings();
|
||||||
|
if (response.settings) {
|
||||||
|
autoSyncSettings = response.settings;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load auto-sync settings:', err);
|
||||||
|
// Don't show error for auto-sync, it's optional
|
||||||
|
} finally {
|
||||||
|
loadingAutoSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveLoTW(e) {
|
async function handleSaveLoTW(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -92,6 +121,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveAutoSync(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
savingAutoSync = true;
|
||||||
|
error = null;
|
||||||
|
successAutoSync = false;
|
||||||
|
|
||||||
|
await autoSyncAPI.updateSettings({
|
||||||
|
lotwEnabled: autoSyncSettings.lotwEnabled,
|
||||||
|
lotwIntervalHours: parseInt(autoSyncSettings.lotwIntervalHours),
|
||||||
|
dclEnabled: autoSyncSettings.dclEnabled,
|
||||||
|
dclIntervalHours: parseInt(autoSyncSettings.dclIntervalHours),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Auto-sync settings saved successfully!');
|
||||||
|
|
||||||
|
// Reload settings to get updated next sync times
|
||||||
|
await loadAutoSyncSettings();
|
||||||
|
successAutoSync = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Auto-sync save failed:', err);
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
savingAutoSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextSyncTime(dateString) {
|
||||||
|
if (!dateString) return 'Not scheduled';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout();
|
auth.logout();
|
||||||
// Use hard redirect to ensure proper navigation after logout
|
// Use hard redirect to ensure proper navigation after logout
|
||||||
@@ -241,6 +304,116 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Automatic Sync Settings</h2>
|
||||||
|
<p class="help-text">
|
||||||
|
Configure automatic synchronization for LoTW and DCL. The server will automatically
|
||||||
|
sync your QSOs at the specified interval. Credentials must be configured above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if !hasLoTWCredentials && !hasDCLCredentials}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Note:</strong> Configure LoTW or DCL credentials above to enable automatic sync.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form on:submit={handleSaveAutoSync} class="settings-form">
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if successAutoSync}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Auto-sync settings saved successfully!
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h3>LoTW Auto-Sync</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={autoSyncSettings.lotwEnabled}
|
||||||
|
disabled={!hasLoTWCredentials || savingAutoSync}
|
||||||
|
/>
|
||||||
|
Enable LoTW auto-sync
|
||||||
|
</label>
|
||||||
|
{#if !hasLoTWCredentials}
|
||||||
|
<p class="hint">Configure LoTW credentials above first</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lotwIntervalHours">Sync interval (hours)</label>
|
||||||
|
<input
|
||||||
|
id="lotwIntervalHours"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="720"
|
||||||
|
bind:value={autoSyncSettings.lotwIntervalHours}
|
||||||
|
disabled={!autoSyncSettings.lotwEnabled || savingAutoSync}
|
||||||
|
/>
|
||||||
|
<p class="hint">
|
||||||
|
Minimum 1 hour, maximum 720 hours (30 days). Default: 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if autoSyncSettings.lotwEnabled && autoSyncSettings.lotwNextSyncAt}
|
||||||
|
<p class="next-sync-info">
|
||||||
|
Next scheduled sync: <strong>{formatNextSyncTime(autoSyncSettings.lotwNextSyncAt)}</strong>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<h3>DCL Auto-Sync</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={autoSyncSettings.dclEnabled}
|
||||||
|
disabled={!hasDCLCredentials || savingAutoSync}
|
||||||
|
/>
|
||||||
|
Enable DCL auto-sync
|
||||||
|
</label>
|
||||||
|
{#if !hasDCLCredentials}
|
||||||
|
<p class="hint">Configure DCL credentials above first</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dclIntervalHours">Sync interval (hours)</label>
|
||||||
|
<input
|
||||||
|
id="dclIntervalHours"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="720"
|
||||||
|
bind:value={autoSyncSettings.dclIntervalHours}
|
||||||
|
disabled={!autoSyncSettings.dclEnabled || savingAutoSync}
|
||||||
|
/>
|
||||||
|
<p class="hint">
|
||||||
|
Minimum 1 hour, maximum 720 hours (30 days). Default: 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if autoSyncSettings.dclEnabled && autoSyncSettings.dclNextSyncAt}
|
||||||
|
<p class="next-sync-info">
|
||||||
|
Next scheduled sync: <strong>{formatNextSyncTime(autoSyncSettings.dclNextSyncAt)}</strong>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={savingAutoSync}>
|
||||||
|
{savingAutoSync ? 'Saving...' : 'Save Auto-Sync Settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -442,4 +615,58 @@
|
|||||||
.info-box a:hover {
|
.info-box a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auto-sync specific styles */
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"]:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-sync-info {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 4px solid #4a90e2;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user