Add automatic synchronization scheduler that allows users to configure periodic sync intervals for LoTW and DCL via the settings page. Features: - Users can enable/disable auto-sync per service (LoTW/DCL) - Configurable sync intervals (1-720 hours) - Settings page UI for managing auto-sync preferences - Dashboard shows upcoming scheduled auto-sync jobs - Scheduler runs every minute, triggers syncs when due - Survives server restarts via database persistence - Graceful shutdown support (SIGINT/SIGTERM) Backend: - New autoSyncSettings table with user preferences - auto-sync.service.js for CRUD operations and scheduling logic - scheduler.service.js for periodic tick processing - API endpoints: GET/PUT /auto-sync/settings, GET /auto-sync/scheduler/status Frontend: - Auto-sync settings section in settings page - Upcoming auto-sync section on dashboard with scheduled job cards - Purple-themed UI for scheduled jobs with countdown animation Co-Authored-By: Claude <noreply@anthropic.com>
262 lines
9.9 KiB
JavaScript
262 lines
9.9 KiB
JavaScript
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
|
|
/**
|
|
* @typedef {Object} User
|
|
* @property {number} id
|
|
* @property {string} email
|
|
* @property {string} passwordHash
|
|
* @property {string} callsign
|
|
* @property {string|null} lotwUsername
|
|
* @property {string|null} lotwPassword
|
|
* @property {string|null} dclApiKey
|
|
* @property {boolean} isAdmin
|
|
* @property {Date} createdAt
|
|
* @property {Date} updatedAt
|
|
*/
|
|
|
|
export const users = sqliteTable('users', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
email: text('email').notNull().unique(),
|
|
passwordHash: text('password_hash').notNull(),
|
|
callsign: text('callsign').notNull(),
|
|
lotwUsername: text('lotw_username'),
|
|
lotwPassword: text('lotw_password'), // Encrypted
|
|
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
|
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} QSO
|
|
* @property {number} id
|
|
* @property {number} userId
|
|
* @property {string} callsign
|
|
* @property {string} qsoDate
|
|
* @property {string} timeOn
|
|
* @property {string|null} band
|
|
* @property {string|null} mode
|
|
* @property {number|null} freq
|
|
* @property {number|null} freqRx
|
|
* @property {string|null} entity
|
|
* @property {number|null} entityId
|
|
* @property {string|null} grid
|
|
* @property {string|null} gridSource
|
|
* @property {string|null} continent
|
|
* @property {number|null} cqZone
|
|
* @property {number|null} ituZone
|
|
* @property {string|null} state
|
|
* @property {string|null} county
|
|
* @property {string|null} satName
|
|
* @property {string|null} satMode
|
|
* @property {string|null} myDarcDok
|
|
* @property {string|null} darcDok
|
|
* @property {string|null} lotwQslRdate
|
|
* @property {string|null} lotwQslRstatus
|
|
* @property {string|null} dclQslRdate
|
|
* @property {string|null} dclQslRstatus
|
|
* @property {Date|null} lotwSyncedAt
|
|
* @property {Date} createdAt
|
|
*/
|
|
|
|
export const qsos = sqliteTable('qsos', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
userId: integer('user_id').notNull().references(() => users.id),
|
|
|
|
// QSO fields
|
|
callsign: text('callsign').notNull(),
|
|
qsoDate: text('qso_date').notNull(), // ADIF format: YYYYMMDD
|
|
timeOn: text('time_on').notNull(), // HHMMSS
|
|
band: text('band'), // 160m, 80m, 40m, etc.
|
|
mode: text('mode'), // CW, SSB, FT8, etc.
|
|
freq: integer('freq'), // Frequency in Hz
|
|
freqRx: integer('freq_rx'), // RX frequency (satellite)
|
|
|
|
// Entity/location fields
|
|
entity: text('entity'), // DXCC entity name
|
|
entityId: integer('entity_id'), // DXCC entity number
|
|
grid: text('grid'), // Maidenhead grid square
|
|
gridSource: text('grid_source'), // LOTW, USER, CALC
|
|
continent: text('continent'), // NA, SA, EU, AF, AS, OC, AN
|
|
cqZone: integer('cq_zone'),
|
|
ituZone: integer('itu_zone'),
|
|
state: text('state'), // US state, CA province, etc.
|
|
county: text('county'),
|
|
|
|
// Satellite fields
|
|
satName: text('sat_name'),
|
|
satMode: text('sat_mode'),
|
|
|
|
// DARC DOK fields (DARC Ortsverband Kennung - German local club identifier)
|
|
myDarcDok: text('my_darc_dok'), // User's own DOK (e.g., 'F03', 'P30')
|
|
darcDok: text('darc_dok'), // QSO partner's DOK
|
|
|
|
// LoTW confirmation
|
|
lotwQslRdate: text('lotw_qsl_rdate'), // Confirmation date
|
|
lotwQslRstatus: text('lotw_qsl_rstatus'), // 'Y', 'N', '?'
|
|
|
|
// DCL confirmation (DARC Community Logbook)
|
|
dclQslRdate: text('dcl_qsl_rdate'), // Confirmation date
|
|
dclQslRstatus: text('dcl_qsl_rstatus'), // 'Y', 'N', '?'
|
|
|
|
// Cache metadata
|
|
lotwSyncedAt: integer('lotw_synced_at', { mode: 'timestamp' }),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} Award
|
|
* @property {string} id
|
|
* @property {string} name
|
|
* @property {string|null} description
|
|
* @property {string} definition
|
|
* @property {boolean} isActive
|
|
* @property {Date} createdAt
|
|
*/
|
|
|
|
export const awards = sqliteTable('awards', {
|
|
id: text('id').primaryKey(), // 'dxcc', 'was', 'vucc'
|
|
name: text('name').notNull(),
|
|
description: text('description'),
|
|
definition: text('definition').notNull(), // JSON rule definition
|
|
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} AwardProgress
|
|
* @property {number} id
|
|
* @property {number} userId
|
|
* @property {string} awardId
|
|
* @property {number} workedCount
|
|
* @property {number} confirmedCount
|
|
* @property {number} totalRequired
|
|
* @property {string|null} workedEntities
|
|
* @property {string|null} confirmedEntities
|
|
* @property {Date|null} lastCalculatedAt
|
|
* @property {Date|null} lastQsoSyncAt
|
|
* @property {Date} updatedAt
|
|
*/
|
|
|
|
export const awardProgress = sqliteTable('award_progress', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
userId: integer('user_id').notNull().references(() => users.id),
|
|
awardId: text('award_id').notNull().references(() => awards.id),
|
|
|
|
// Calculated progress
|
|
workedCount: integer('worked_count').notNull().default(0),
|
|
confirmedCount: integer('confirmed_count').notNull().default(0),
|
|
totalRequired: integer('total_required').notNull(),
|
|
|
|
// Detailed breakdown (JSON)
|
|
workedEntities: text('worked_entities'), // JSON array
|
|
confirmedEntities: text('confirmed_entities'), // JSON array
|
|
|
|
// Cache control
|
|
lastCalculatedAt: integer('last_calculated_at', { mode: 'timestamp' }),
|
|
lastQsoSyncAt: integer('last_qso_sync_at', { mode: 'timestamp' }),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} SyncJob
|
|
* @property {number} id
|
|
* @property {number} userId
|
|
* @property {string} status
|
|
* @property {string} type
|
|
* @property {Date|null} startedAt
|
|
* @property {Date|null} completedAt
|
|
* @property {string|null} result
|
|
* @property {string|null} error
|
|
* @property {Date} createdAt
|
|
*/
|
|
|
|
export const syncJobs = sqliteTable('sync_jobs', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
userId: integer('user_id').notNull().references(() => users.id),
|
|
status: text('status').notNull(), // pending, running, completed, failed
|
|
type: text('type').notNull(), // lotw_sync, etc.
|
|
startedAt: integer('started_at', { mode: 'timestamp' }),
|
|
completedAt: integer('completed_at', { mode: 'timestamp' }),
|
|
result: text('result'), // JSON string
|
|
error: text('error'),
|
|
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()),
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} AdminAction
|
|
* @property {number} id
|
|
* @property {number} adminId
|
|
* @property {string} actionType
|
|
* @property {number|null} targetUserId
|
|
* @property {string|null} details
|
|
* @property {Date} createdAt
|
|
*/
|
|
|
|
export const adminActions = sqliteTable('admin_actions', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
adminId: integer('admin_id').notNull().references(() => users.id),
|
|
actionType: text('action_type').notNull(), // 'impersonate_start', 'impersonate_stop', 'role_change', 'user_delete', etc.
|
|
targetUserId: integer('target_user_id').references(() => users.id),
|
|
details: text('details'), // JSON with additional context
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} AutoSyncSettings
|
|
* @property {number} userId
|
|
* @property {boolean} lotwEnabled
|
|
* @property {number} lotwIntervalHours
|
|
* @property {Date|null} lotwLastSyncAt
|
|
* @property {Date|null} lotwNextSyncAt
|
|
* @property {boolean} dclEnabled
|
|
* @property {number} dclIntervalHours
|
|
* @property {Date|null} dclLastSyncAt
|
|
* @property {Date|null} dclNextSyncAt
|
|
* @property {Date} createdAt
|
|
* @property {Date} updatedAt
|
|
*/
|
|
|
|
export const autoSyncSettings = sqliteTable('auto_sync_settings', {
|
|
userId: integer('user_id').primaryKey().references(() => users.id),
|
|
|
|
// LoTW auto-sync settings
|
|
lotwEnabled: integer('lotw_enabled', { mode: 'boolean' }).notNull().default(false),
|
|
lotwIntervalHours: integer('lotw_interval_hours').notNull().default(24),
|
|
lotwLastSyncAt: integer('lotw_last_sync_at', { mode: 'timestamp' }),
|
|
lotwNextSyncAt: integer('lotw_next_sync_at', { mode: 'timestamp' }),
|
|
|
|
// DCL auto-sync settings
|
|
dclEnabled: integer('dcl_enabled', { mode: 'boolean' }).notNull().default(false),
|
|
dclIntervalHours: integer('dcl_interval_hours').notNull().default(24),
|
|
dclLastSyncAt: integer('dcl_last_sync_at', { mode: 'timestamp' }),
|
|
dclNextSyncAt: integer('dcl_next_sync_at', { mode: 'timestamp' }),
|
|
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
|
});
|
|
|
|
// Export all schemas
|
|
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };
|