feat: add sync job cancel and rollback with real-time updates

Implement comprehensive sync job management with rollback capabilities
and real-time status updates on the dashboard.

## Features

### Cancel & Rollback
- Users can cancel failed or stale (>1h) sync jobs
- Rollback deletes added QSOs and restores updated QSOs to previous state
- Uses qso_changes table to track all modifications with before/after snapshots
- Server-side validation prevents cancelling completed or active jobs

### Database Changes
- Add qso_changes table to track QSO modifications per job
- Stores change type (added/updated), before/after data snapshots
- Enables precise rollback of sync operations
- Migration script included

### Real-time Updates
- Dashboard now polls for job updates every 2 seconds
- Smart polling: starts when jobs active, stops when complete
- Job status badges update in real-time (pending → running → completed)
- Cancel button appears/disappears based on job state

### Backend
- Fixed job ordering to show newest first (desc createdAt)
- Track all QSO changes during LoTW/DCL sync operations
- cancelJob() function handles rollback logic
- DELETE /api/jobs/:jobId endpoint for cancelling jobs

### Frontend
- jobsAPI.cancel() method for cancelling jobs
- Dashboard shows last 5 sync jobs with status, stats, duration
- Real-time job status updates via polling
- Cancel button with confirmation dialog
- Loading state and error handling

### Logging Fix
- Changed from Bun.write() to fs.appendFile() for reliable log appending
- Logs now persist across server restarts instead of being truncated

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 11:46:19 +01:00
parent 56be3c0702
commit a50b4ae724
9 changed files with 502 additions and 26 deletions

View File

@@ -181,5 +181,26 @@ export const syncJobs = sqliteTable('sync_jobs', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
/**
* @typedef {Object} QSOChange
* @property {number} id
* @property {number} jobId
* @property {number|null} qsoId
* @property {string} changeType - 'added' or 'updated'
* @property {string|null} beforeData - JSON snapshot before change (for updates)
* @property {string|null} afterData - JSON snapshot after change
* @property {Date} createdAt
*/
export const qsoChanges = sqliteTable('qso_changes', {
id: integer('id').primaryKey({ autoIncrement: true }),
jobId: integer('job_id').notNull().references(() => syncJobs.id),
qsoId: integer('qso_id').references(() => qsos.id), // null for added QSOs until created
changeType: text('change_type').notNull(), // 'added' or 'updated'
beforeData: text('before_data'), // JSON snapshot before change
afterData: text('after_data'), // JSON snapshot after change
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
// Export all schemas
export const schema = { users, qsos, awards, awardProgress, syncJobs };
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges };