400 lines
12 KiB
JavaScript
400 lines
12 KiB
JavaScript
import { db, logger } from '../config.js';
|
|
import { qsos } from '../db/schema/index.js';
|
|
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
|
import { updateJobProgress } from './job-queue.service.js';
|
|
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
|
|
|
/**
|
|
* DCL (DARC Community Logbook) Service
|
|
*
|
|
* DCL Information:
|
|
* - Website: https://dcl.darc.de/
|
|
* - API Endpoint: https://dings.dcl.darc.de/api/adiexport
|
|
* - DOK fields: MY_DARC_DOK (user's DOK), DARC_DOK (partner's DOK)
|
|
*
|
|
* API Request Format (POST):
|
|
* {
|
|
* "key": "API_KEY",
|
|
* "limit": null,
|
|
* "qsl_since": null,
|
|
* "qso_since": null,
|
|
* "cnf_only": null
|
|
* }
|
|
*
|
|
* Expected API Response Format:
|
|
* {
|
|
* "adif": "<ADIF_VER:5>3.1.3\\n<CREATED_TIMESTAMP:15>20260117 095453\\n<EOH>\\n..."
|
|
* }
|
|
*/
|
|
|
|
const REQUEST_TIMEOUT = 60000;
|
|
const DCL_API_URL = 'https://dings.dcl.darc.de/api/adiexport';
|
|
|
|
/**
|
|
* Fetch QSOs from DCL API
|
|
*
|
|
* @param {string} dclApiKey - DCL API key
|
|
* @param {Date|null} sinceDate - Last sync date for incremental sync
|
|
* @returns {Promise<Array>} Array of parsed QSO records
|
|
*/
|
|
export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
|
|
logger.info('Fetching QSOs from DCL', {
|
|
hasApiKey: !!dclApiKey,
|
|
sinceDate: sinceDate?.toISOString(),
|
|
});
|
|
|
|
// Build request body
|
|
const requestBody = {
|
|
key: dclApiKey,
|
|
limit: 50000,
|
|
qsl_since: null,
|
|
qso_since: null,
|
|
cnf_only: null,
|
|
};
|
|
|
|
// Add date filter for incremental sync if provided
|
|
if (sinceDate) {
|
|
const dateStr = sinceDate.toISOString().split('T')[0].replace(/-/g, '');
|
|
requestBody.qsl_since = dateStr;
|
|
}
|
|
|
|
// Debug log request parameters (redact API key)
|
|
logger.debug('DCL API request parameters', {
|
|
url: DCL_API_URL,
|
|
method: 'POST',
|
|
key: dclApiKey ? `${dclApiKey.substring(0, 4)}...${dclApiKey.substring(dclApiKey.length - 4)}` : null,
|
|
limit: requestBody.limit,
|
|
qsl_since: requestBody.qsl_since,
|
|
qso_since: requestBody.qso_since,
|
|
cnf_only: requestBody.cnf_only,
|
|
});
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
|
|
|
const response = await fetch(DCL_API_URL, {
|
|
method: 'POST',
|
|
signal: controller.signal,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
throw new Error('Invalid DCL API key. Please check your DCL credentials in Settings.');
|
|
} else if (response.status === 404) {
|
|
throw new Error('DCL API endpoint not found.');
|
|
} else {
|
|
const errorText = await response.text();
|
|
throw new Error(`DCL API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
}
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Parse the DCL response format
|
|
const qsos = parseDCLResponse(data);
|
|
|
|
logger.info('Successfully fetched QSOs from DCL', {
|
|
total: qsos.length,
|
|
hasConfirmations: qsos.filter(q => q.dcl_qsl_rcvd === 'Y').length,
|
|
});
|
|
|
|
return qsos;
|
|
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
throw new Error('DCL API request timed out. Please try again.');
|
|
}
|
|
|
|
logger.error('Failed to fetch from DCL', {
|
|
error: error.message,
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse DCL API response from JSON
|
|
* Can be used for testing with example payloads
|
|
*
|
|
* @param {Object} jsonResponse - JSON response in DCL format
|
|
* @returns {Array} Array of parsed QSO records
|
|
*/
|
|
export function parseDCLJSONResponse(jsonResponse) {
|
|
return parseDCLResponse(jsonResponse);
|
|
}
|
|
|
|
/**
|
|
* Convert DCL ADIF QSO to database format
|
|
* @param {Object} adifQSO - Parsed ADIF QSO record
|
|
* @param {number} userId - User ID
|
|
* @returns {Object} Database-ready QSO object
|
|
*/
|
|
function convertQSODatabaseFormat(adifQSO, userId) {
|
|
return {
|
|
userId,
|
|
callsign: adifQSO.call || '',
|
|
qsoDate: adifQSO.qso_date || '',
|
|
timeOn: adifQSO.time_on || adifQSO.time_off || '000000',
|
|
band: normalizeBand(adifQSO.band),
|
|
mode: normalizeMode(adifQSO.mode),
|
|
freq: adifQSO.freq ? parseInt(adifQSO.freq) : null,
|
|
freqRx: adifQSO.freq_rx ? parseInt(adifQSO.freq_rx) : null,
|
|
// DCL may or may not include DXCC fields - use them if available
|
|
entity: adifQSO.country || adifQSO.dxcc_country || '',
|
|
entityId: adifQSO.dxcc ? parseInt(adifQSO.dxcc) : null,
|
|
grid: adifQSO.gridsquare || '',
|
|
gridSource: adifQSO.gridsquare ? 'DCL' : null,
|
|
continent: adifQSO.continent || '',
|
|
cqZone: adifQSO.cq_zone ? parseInt(adifQSO.cq_zone) : null,
|
|
ituZone: adifQSO.itu_zone ? parseInt(adifQSO.itu_zone) : null,
|
|
state: adifQSO.state || adifQSO.us_state || '',
|
|
county: adifQSO.county || '',
|
|
satName: adifQSO.sat_name || '',
|
|
satMode: adifQSO.sat_mode || '',
|
|
myDarcDok: adifQSO.my_darc_dok || '',
|
|
darcDok: adifQSO.darc_dok || '',
|
|
// DCL confirmation fields
|
|
dclQslRdate: adifQSO.dcl_qslrdate || '',
|
|
dclQslRstatus: adifQSO.dcl_qsl_rcvd || 'N',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sync QSOs from DCL to database
|
|
* Updates existing QSOs with DCL confirmation data
|
|
*
|
|
* @param {number} userId - User ID
|
|
* @param {string} dclApiKey - DCL API key
|
|
* @param {Date|null} sinceDate - Last sync date for incremental sync
|
|
* @param {number|null} jobId - Job ID for progress tracking
|
|
* @returns {Promise<Object>} Sync results
|
|
*/
|
|
export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null) {
|
|
logger.info('Starting DCL sync', { userId, sinceDate, jobId });
|
|
|
|
if (jobId) {
|
|
await updateJobProgress(jobId, {
|
|
message: 'Fetching QSOs from DCL...',
|
|
step: 'fetch',
|
|
});
|
|
}
|
|
|
|
try {
|
|
const adifQSOs = await fetchQSOsFromDCL(dclApiKey, sinceDate);
|
|
|
|
if (!Array.isArray(adifQSOs) || adifQSOs.length === 0) {
|
|
logger.info('No QSOs found in DCL response', { userId });
|
|
return {
|
|
success: true,
|
|
total: 0,
|
|
added: 0,
|
|
updated: 0,
|
|
message: 'No QSOs found in DCL',
|
|
};
|
|
}
|
|
|
|
if (jobId) {
|
|
await updateJobProgress(jobId, {
|
|
message: `Processing ${adifQSOs.length} QSOs from DCL...`,
|
|
step: 'process',
|
|
total: adifQSOs.length,
|
|
processed: 0,
|
|
});
|
|
}
|
|
|
|
let addedCount = 0;
|
|
let updatedCount = 0;
|
|
let skippedCount = 0;
|
|
const errors = [];
|
|
const addedQSOs = [];
|
|
const updatedQSOs = [];
|
|
|
|
for (let i = 0; i < adifQSOs.length; i++) {
|
|
const adifQSO = adifQSOs[i];
|
|
|
|
try {
|
|
const dbQSO = convertQSODatabaseFormat(adifQSO, userId);
|
|
|
|
// Check if QSO already exists (match by callsign, date, time, band, mode)
|
|
const existing = await db
|
|
.select()
|
|
.from(qsos)
|
|
.where(
|
|
and(
|
|
eq(qsos.userId, userId),
|
|
eq(qsos.callsign, dbQSO.callsign),
|
|
eq(qsos.qsoDate, dbQSO.qsoDate),
|
|
eq(qsos.timeOn, dbQSO.timeOn),
|
|
eq(qsos.band, dbQSO.band),
|
|
eq(qsos.mode, dbQSO.mode)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
const existingQSO = existing[0];
|
|
|
|
// Check if DCL confirmation or DOK data has changed
|
|
const dataChanged =
|
|
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
|
|
existingQSO.dclQslRdate !== dbQSO.dclQslRdate ||
|
|
existingQSO.darcDok !== (dbQSO.darcDok || existingQSO.darcDok) ||
|
|
existingQSO.myDarcDok !== (dbQSO.myDarcDok || existingQSO.myDarcDok) ||
|
|
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
|
|
|
|
if (dataChanged) {
|
|
// Update existing QSO with changed DCL confirmation and DOK data
|
|
const updateData = {
|
|
dclQslRdate: dbQSO.dclQslRdate,
|
|
dclQslRstatus: dbQSO.dclQslRstatus,
|
|
};
|
|
|
|
// Only add DOK fields if DCL sent them
|
|
if (dbQSO.darcDok) updateData.darcDok = dbQSO.darcDok;
|
|
if (dbQSO.myDarcDok) updateData.myDarcDok = dbQSO.myDarcDok;
|
|
|
|
// Only update grid if DCL sent one
|
|
if (dbQSO.grid) {
|
|
updateData.grid = dbQSO.grid;
|
|
updateData.gridSource = dbQSO.gridSource;
|
|
}
|
|
|
|
// DXCC priority: LoTW > DCL
|
|
// Only update entity fields from DCL if:
|
|
// 1. QSO is NOT LoTW confirmed, AND
|
|
// 2. DCL actually sent entity data, AND
|
|
// 3. Current entity is missing
|
|
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
|
|
const hasDCLData = dbQSO.entity || dbQSO.entityId;
|
|
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
|
|
|
|
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
|
// Fill in entity data from DCL (only if DCL provides it)
|
|
if (dbQSO.entity) updateData.entity = dbQSO.entity;
|
|
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
|
|
if (dbQSO.continent) updateData.continent = dbQSO.continent;
|
|
if (dbQSO.cqZone) updateData.cqZone = dbQSO.cqZone;
|
|
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
|
|
}
|
|
|
|
await db
|
|
.update(qsos)
|
|
.set(updateData)
|
|
.where(eq(qsos.id, existingQSO.id));
|
|
updatedCount++;
|
|
// Track updated QSO (CALL and DATE)
|
|
updatedQSOs.push({
|
|
callsign: dbQSO.callsign,
|
|
date: dbQSO.qsoDate,
|
|
band: dbQSO.band,
|
|
mode: dbQSO.mode,
|
|
});
|
|
} else {
|
|
// Skip - same data
|
|
skippedCount++;
|
|
}
|
|
} else {
|
|
// Insert new QSO
|
|
await db.insert(qsos).values(dbQSO);
|
|
addedCount++;
|
|
// Track added QSO (CALL and DATE)
|
|
addedQSOs.push({
|
|
callsign: dbQSO.callsign,
|
|
date: dbQSO.qsoDate,
|
|
band: dbQSO.band,
|
|
mode: dbQSO.mode,
|
|
});
|
|
}
|
|
|
|
// Update job progress every 10 QSOs
|
|
if (jobId && (i + 1) % 10 === 0) {
|
|
await updateJobProgress(jobId, {
|
|
processed: i + 1,
|
|
message: `Processed ${i + 1}/${adifQSOs.length} QSOs from DCL...`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to process DCL QSO', {
|
|
error: error.message,
|
|
qso: adifQSO,
|
|
userId,
|
|
});
|
|
errors.push({ qso: adifQSO, error: error.message });
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
success: true,
|
|
total: adifQSOs.length,
|
|
added: addedCount,
|
|
updated: updatedCount,
|
|
skipped: skippedCount,
|
|
addedQSOs,
|
|
updatedQSOs,
|
|
confirmed: adifQSOs.filter(q => q.dcl_qsl_rcvd === 'Y').length,
|
|
errors: errors.length > 0 ? errors : undefined,
|
|
};
|
|
|
|
logger.info('DCL sync completed', {
|
|
...result,
|
|
userId,
|
|
jobId,
|
|
});
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
logger.error('DCL sync failed', {
|
|
error: error.message,
|
|
userId,
|
|
jobId,
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
total: 0,
|
|
added: 0,
|
|
updated: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get last DCL QSL date for incremental sync
|
|
*
|
|
* @param {number} userId - User ID
|
|
* @returns {Promise<Date|null>} Last QSL date or null
|
|
*/
|
|
export async function getLastDCLQSLDate(userId) {
|
|
try {
|
|
const result = await db
|
|
.select({ maxDate: max(qsos.dclQslRdate) })
|
|
.from(qsos)
|
|
.where(eq(qsos.userId, userId));
|
|
|
|
if (result[0]?.maxDate) {
|
|
// Convert ADIF date format (YYYYMMDD) to Date
|
|
const dateStr = result[0].maxDate;
|
|
const year = dateStr.substring(0, 4);
|
|
const month = dateStr.substring(4, 6);
|
|
const day = dateStr.substring(6, 8);
|
|
return new Date(`${year}-${month}-${day}`);
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Failed to get last DCL QSL date', { error: error.message, userId });
|
|
return null;
|
|
}
|
|
}
|