Refactor LoTW sync with background job queue and Wavelog compatibility
Backend changes:
- Add sync_jobs table for background job tracking with Drizzle schema
- Create job queue service (job-queue.service.js) for async job processing
- Only ONE active sync job per user enforced at queue level
- Refactor LoTW service with Wavelog download logic:
- Validate for "Username/password incorrect" in response
- Check file starts with "ARRL Logbook of the World Status Report"
- Use last LoTW QSL date for incremental sync (qso_qslsince)
- Wavelog-compatible timeouts and error handling
- Add deleteQSOs function to clear all user QSOs
- Fix database path to use absolute path for consistency
- Register job processor for lotw_sync job type
API endpoints:
- POST /api/lotw/sync - Queue background sync job, returns jobId immediately
- GET /api/jobs/:jobId - Get job status with progress tracking
- GET /api/jobs/active - Get user's active job
- GET /api/jobs - Get user's recent jobs
- DELETE /api/qsos/all - Delete all QSOs for authenticated user
Frontend changes:
- Add job polling every 2 seconds during sync
- Show real-time progress indicator during sync
- Add "Clear All QSOs" button with type-to-confirm ("DELETE")
- Check for active job on mount to resume polling after refresh
- Clean up polling interval on component unmount
- Update API client with jobsAPI methods (getStatus, getActive, getRecent)
Database:
- Add sync_jobs table: id, userId, status, type, startedAt, completedAt,
result, error, createdAt
- Foreign key to users table
- Path fix: now uses src/backend/award.db consistently
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,16 @@ import {
|
||||
updateLoTWCredentials,
|
||||
} from './services/auth.service.js';
|
||||
import {
|
||||
syncQSOs,
|
||||
getUserQSOs,
|
||||
getQSOStats,
|
||||
deleteQSOs,
|
||||
} from './services/lotw.service.js';
|
||||
import {
|
||||
enqueueJob,
|
||||
getJobStatus,
|
||||
getUserActiveJob,
|
||||
getUserJobs,
|
||||
} from './services/job-queue.service.js';
|
||||
|
||||
/**
|
||||
* Main backend application
|
||||
@@ -218,18 +224,29 @@ const app = new Elysia()
|
||||
|
||||
/**
|
||||
* POST /api/lotw/sync
|
||||
* Sync QSOs from LoTW (requires authentication)
|
||||
* Queue a LoTW sync job (requires authentication)
|
||||
* Returns immediately with job ID
|
||||
*/
|
||||
.post('/api/lotw/sync', async ({ user, set }) => {
|
||||
if (!user) {
|
||||
console.error('[/api/lotw/sync] No user found in request');
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
console.error('[/api/lotw/sync] User authenticated:', user.id);
|
||||
|
||||
try {
|
||||
// Get user's LoTW credentials from database
|
||||
const userData = await getUserById(user.id);
|
||||
console.error('[/api/lotw/sync] User data from DB:', {
|
||||
id: userData?.id,
|
||||
lotwUsername: userData?.lotwUsername ? '***' : null,
|
||||
hasPassword: !!userData?.lotwPassword
|
||||
});
|
||||
|
||||
if (!userData || !userData.lotwUsername || !userData.lotwPassword) {
|
||||
console.error('[/api/lotw/sync] Missing LoTW credentials');
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
@@ -237,18 +254,136 @@ const app = new Elysia()
|
||||
};
|
||||
}
|
||||
|
||||
// Decrypt password (for now, assuming it's stored as-is. TODO: implement encryption)
|
||||
const lotwPassword = userData.lotwPassword;
|
||||
// Enqueue the sync job (enqueueJob will check for existing active jobs)
|
||||
const result = await enqueueJob(user.id, 'lotw_sync', {
|
||||
lotwUsername: userData.lotwUsername,
|
||||
lotwPassword: userData.lotwPassword,
|
||||
});
|
||||
|
||||
// Sync QSOs from LoTW
|
||||
const result = await syncQSOs(user.id, userData.lotwUsername, lotwPassword);
|
||||
// If enqueueJob returned existingJob, format the response
|
||||
if (!result.success && result.existingJob) {
|
||||
return {
|
||||
success: true,
|
||||
jobId: result.existingJob,
|
||||
message: 'A sync job is already running',
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in /api/lotw/sync:', error);
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to queue sync job: ${error.message}`,
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/jobs/:jobId
|
||||
* Get job status (requires authentication)
|
||||
*/
|
||||
.get('/api/jobs/:jobId', async ({ user, params, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = parseInt(params.jobId);
|
||||
if (isNaN(jobId)) {
|
||||
set.status = 400;
|
||||
return { success: false, error: 'Invalid job ID' };
|
||||
}
|
||||
|
||||
const job = await getJobStatus(jobId);
|
||||
if (!job) {
|
||||
set.status = 404;
|
||||
return { success: false, error: 'Job not found' };
|
||||
}
|
||||
|
||||
// Verify user owns this job
|
||||
if (job.userId !== user.id) {
|
||||
set.status = 403;
|
||||
return { success: false, error: 'Forbidden' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: `LoTW sync failed: ${error.message}`,
|
||||
error: 'Failed to fetch job status',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/jobs/active
|
||||
* Get user's active job (requires authentication)
|
||||
*/
|
||||
.get('/api/jobs/active', async ({ user, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await getUserActiveJob(user.id);
|
||||
|
||||
if (!job) {
|
||||
return {
|
||||
success: true,
|
||||
job: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job: {
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt,
|
||||
startedAt: job.startedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch active job',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/jobs
|
||||
* Get user's recent jobs (requires authentication)
|
||||
*/
|
||||
.get('/api/jobs', async ({ user, query, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const limit = query.limit ? parseInt(query.limit) : 10;
|
||||
const jobs = await getUserJobs(user.id, limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
jobs,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch jobs',
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -311,6 +446,33 @@ const app = new Elysia()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /api/qsos/all
|
||||
* Delete all QSOs for authenticated user
|
||||
*/
|
||||
.delete('/api/qsos/all', async ({ user, set }) => {
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await deleteQSOs(user.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deleted,
|
||||
message: `Deleted ${deleted} QSO(s)`,
|
||||
};
|
||||
} catch (error) {
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to delete QSOs',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
// Health check endpoint
|
||||
.get('/api/health', () => ({
|
||||
status: 'ok',
|
||||
|
||||
Reference in New Issue
Block a user