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": "3.1.3\\n20260117 095453\\n\\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 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} 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} 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; } }