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:
2026-01-15 21:47:50 +01:00
parent 44c13e1bdc
commit f82fc876ce
7 changed files with 1016 additions and 52 deletions

View File

@@ -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',