Files
award/src/backend/services/dcl.service.js
2026-01-19 07:39:33 +01:00

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;
}
}