When auto-sync is enabled but credentials (LoTW/DCL) are removed, the scheduler would continuously try to sync every minute, logging the same warning forever. Now: - Split pending users into those with and without credentials - For users without credentials, update nextSyncAt to retry in 24 hours - Log a warning with affected user IDs - Only return users with valid credentials for job processing This prevents log spam and unnecessary database queries while still periodically checking if credentials have been restored. Co-Authored-By: Claude <noreply@anthropic.com>
374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
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;
|
|
}
|
|
}
|