Files
award/src/backend/services/auto-sync.service.js
Joerg 69b33720b3 fix: prevent infinite retry loop for auto-sync users without credentials
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>
2026-01-22 13:17:16 +01:00

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;
}
}