import { db, logger } from '../config.js'; import { qsos } from '../db/schema/index.js'; import { eq, and, or, desc, sql } from 'drizzle-orm'; import { readFileSync, readdirSync } from 'fs'; import { join } from 'path'; import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js'; /** * Awards Service * Calculates award progress based on QSO data */ // Load award definitions from files const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions'); // In-memory cache for award definitions (static, never changes at runtime) let cachedAwardDefinitions = null; /** * Load all award definitions (cached in memory) */ function loadAwardDefinitions() { // Return cached definitions if available if (cachedAwardDefinitions) { return cachedAwardDefinitions; } const definitions = []; try { // Auto-discover all JSON files in the award-definitions directory const files = readdirSync(AWARD_DEFINITIONS_DIR) .filter(f => f.endsWith('.json')) .sort(); for (const file of files) { try { const filePath = join(AWARD_DEFINITIONS_DIR, file); const content = readFileSync(filePath, 'utf-8'); const definition = JSON.parse(content); definitions.push(definition); } catch (error) { logger.warn('Failed to load award definition', { file, error: error.message }); } } } catch (error) { logger.error('Error loading award definitions', { error: error.message }); } // Cache the definitions for future calls cachedAwardDefinitions = definitions; return definitions; } /** * Get all available awards */ export async function getAllAwards() { const definitions = loadAwardDefinitions(); return definitions.map((def) => ({ id: def.id, name: def.name, description: def.description, caption: def.caption, category: def.category, rules: def.rules, })); } /** * Calculate award progress for a user * @param {number} userId - User ID * @param {Object} award - Award definition * @param {Object} options - Options * @param {boolean} options.includeDetails - Include detailed entity breakdown */ export async function calculateAwardProgress(userId, award, options = {}) { const { includeDetails = false } = options; let { rules } = award; // Normalize rules inline to handle different formats // Handle "filtered" type awards (like DXCC CW) if (rules.type === 'filtered' && rules.baseRule) { rules = { type: 'entity', entityType: rules.baseRule.entityType, target: rules.baseRule.target, displayField: rules.baseRule.displayField, filters: rules.filters, }; } // Handle "counter" type awards (like RS-44) else if (rules.type === 'counter') { rules = { type: 'entity', entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign', target: rules.target, displayField: rules.displayField, filters: rules.filters, }; } // Validate "points" type awards else if (rules.type === 'points') { if (!rules.stations || !Array.isArray(rules.stations)) { logger.warn('Point-based award missing stations array'); } } logger.debug('Calculating award progress', { userId, awardId: award.id, awardType: rules.type, entityType: rules.entityType, hasFilters: !!rules.filters, }); // Handle DOK-based awards (DLD) if (rules.type === 'dok') { return calculateDOKAwardProgress(userId, award, { includeDetails }); } // Handle point-based awards if (rules.type === 'points') { return calculatePointsAwardProgress(userId, award, { includeDetails }); } // Get all QSOs for user const allQSOs = await db .select() .from(qsos) .where(eq(qsos.userId, userId)); logger.debug('Total QSOs for user', { count: allQSOs.length }); // Apply filters if defined let filteredQSOs = allQSOs; if (rules.filters) { filteredQSOs = applyFilters(allQSOs, rules.filters); logger.debug('QSOs after filters', { count: filteredQSOs.length }); } // Apply allowed_bands filter if present let finalQSOs = filteredQSOs; if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) { finalQSOs = filteredQSOs.filter(qso => { const band = qso.band; return rules.allowed_bands.includes(band); }); logger.debug('QSOs after allowed_bands filter', { count: finalQSOs.length }); } // Apply satellite_only filter if present if (rules.satellite_only) { finalQSOs = finalQSOs.filter(qso => qso.satName); logger.debug('QSOs after satellite_only filter', { count: finalQSOs.length }); } // Calculate worked and confirmed entities const workedEntities = new Set(); const confirmedEntities = new Set(); for (const qso of finalQSOs) { const entity = getEntityValue(qso, rules.entityType); if (entity) { // Worked: QSO exists (any LoTW status) workedEntities.add(entity); // Confirmed: LoTW QSL received if (qso.lotwQslRstatus === 'Y') { confirmedEntities.add(entity); } } } return { worked: workedEntities.size, confirmed: confirmedEntities.size, target: rules.target || 0, percentage: rules.target ? Math.round((confirmedEntities.size / rules.target) * 100) : 0, workedEntities: Array.from(workedEntities), confirmedEntities: Array.from(confirmedEntities), }; } /** * Calculate progress for DOK-based awards (DLD) * Counts unique (DOK, band, mode) combinations with DCL confirmation * @param {number} userId - User ID * @param {Object} award - Award definition * @param {Object} options - Options * @param {boolean} options.includeDetails - Include detailed entity breakdown */ async function calculateDOKAwardProgress(userId, award, options = {}) { const { includeDetails = false } = options; const { rules } = award; const { target, displayField, filters } = rules; logger.debug('Calculating DOK-based award progress', { userId, awardId: award.id, target, hasFilters: !!filters }); // Get all QSOs for user const allQSOs = await db .select() .from(qsos) .where(eq(qsos.userId, userId)); logger.debug('Total QSOs for user', { count: allQSOs.length }); // Apply filters if defined let filteredQSOs = allQSOs; if (filters) { filteredQSOs = applyFilters(allQSOs, filters); logger.debug('QSOs after DOK award filters', { count: filteredQSOs.length }); } // Track unique (DOK, band, mode) combinations const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object with qsos array for (const qso of filteredQSOs) { const dok = qso.darcDok; if (!dok) continue; // Skip QSOs without DOK const band = qso.band || 'Unknown'; const mode = qso.mode || 'Unknown'; const combinationKey = `${dok}/${band}/${mode}`; // Initialize combination if not exists if (!dokCombinations.has(combinationKey)) { dokCombinations.set(combinationKey, { entity: dok, entityId: null, entityName: dok, band, mode, worked: false, confirmed: false, qsos: [], // Array of confirmed QSOs for this slot }); } const detail = dokCombinations.get(combinationKey); detail.worked = true; // Check for DCL confirmation and add to qsos array if (qso.dclQslRstatus === 'Y') { if (!detail.confirmed) { detail.confirmed = true; } // Add this confirmed QSO to the qsos array detail.qsos.push({ qsoId: qso.id, callsign: qso.callsign, mode: qso.mode, qsoDate: qso.qsoDate, timeOn: qso.timeOn, band: qso.band, satName: qso.satName, confirmed: true, }); } } const workedDOKs = new Set(); const confirmedDOKs = new Set(); for (const [key, detail] of dokCombinations) { const dok = detail.entity; workedDOKs.add(dok); if (detail.confirmed) { confirmedDOKs.add(dok); } } logger.debug('DOK award progress', { workedDOKs: workedDOKs.size, confirmedDOKs: confirmedDOKs.size, target, }); // Base result const result = { worked: workedDOKs.size, confirmed: confirmedDOKs.size, target: target || 0, percentage: target ? Math.round((confirmedDOKs.size / target) * 100) : 0, workedEntities: Array.from(workedDOKs), confirmedEntities: Array.from(confirmedDOKs), }; // Add details if requested if (includeDetails) { result.award = { id: award.id, name: award.name, description: award.description, caption: award.caption, target: target || 0, }; result.entities = Array.from(dokCombinations.values()); result.total = result.entities.length; result.confirmed = result.entities.filter((e) => e.confirmed).length; } return result; } /** * Calculate progress for point-based awards * countMode determines how points are counted: * - "perBandMode": each unique (callsign, band, mode) combination earns points * - "perStation": each unique station earns points once * - "perQso": every confirmed QSO earns points * @param {number} userId - User ID * @param {Object} award - Award definition * @param {Object} options - Options * @param {boolean} options.includeDetails - Include detailed entity breakdown */ async function calculatePointsAwardProgress(userId, award, options = {}) { const { includeDetails = false } = options; const { rules } = award; const { stations, target, countMode = 'perStation' } = rules; // Create a map of callsign -> points for quick lookup const stationPoints = new Map(); for (const station of stations) { stationPoints.set(station.callsign.toUpperCase(), station.points); } logger.debug('Point-based award stations', { totalStations: stations.length, countMode, maxPoints: stations.reduce((sum, s) => sum + s.points, 0), }); // Get all QSOs for user const allQSOs = await db .select() .from(qsos) .where(eq(qsos.userId, userId)); const workedStations = new Set(); let totalPoints = 0; const stationDetails = []; if (countMode === 'perBandMode') { // Count unique (callsign, band, mode) combinations const combinationMap = new Map(); for (const qso of allQSOs) { const callsign = qso.callsign?.toUpperCase(); if (!callsign) continue; const points = stationPoints.get(callsign); if (!points) continue; const band = qso.band || 'Unknown'; const mode = qso.mode || 'Unknown'; const combinationKey = `${callsign}/${band}/${mode}`; workedStations.add(callsign); if (!combinationMap.has(combinationKey)) { combinationMap.set(combinationKey, { callsign, band, mode, points, worked: true, confirmed: false, qsos: [], // Array of confirmed QSOs for this slot }); } if (qso.lotwQslRstatus === 'Y') { const detail = combinationMap.get(combinationKey); if (!detail.confirmed) { detail.confirmed = true; } // Add this confirmed QSO to the qsos array detail.qsos.push({ qsoId: qso.id, callsign: qso.callsign, mode: qso.mode, qsoDate: qso.qsoDate, timeOn: qso.timeOn, band: qso.band, satName: qso.satName, confirmed: true, }); } } const details = Array.from(combinationMap.values()); stationDetails.push(...details); totalPoints = details.filter((d) => d.confirmed).reduce((sum, d) => sum + d.points, 0); } else if (countMode === 'perStation') { // Count unique stations only const stationMap = new Map(); for (const qso of allQSOs) { const callsign = qso.callsign?.toUpperCase(); if (!callsign) continue; const points = stationPoints.get(callsign); if (!points) continue; workedStations.add(callsign); if (!stationMap.has(callsign)) { stationMap.set(callsign, { callsign, points, worked: true, confirmed: false, qsos: [], // Array of confirmed QSOs for this station }); } if (qso.lotwQslRstatus === 'Y') { const detail = stationMap.get(callsign); if (!detail.confirmed) { detail.confirmed = true; } // Add this confirmed QSO to the qsos array detail.qsos.push({ qsoId: qso.id, callsign: qso.callsign, mode: qso.mode, qsoDate: qso.qsoDate, timeOn: qso.timeOn, band: qso.band, satName: qso.satName, confirmed: true, }); } } const details = Array.from(stationMap.values()); stationDetails.push(...details); totalPoints = details.filter((d) => d.confirmed).reduce((sum, d) => sum + d.points, 0); } else if (countMode === 'perQso') { // Count every confirmed QSO for (const qso of allQSOs) { const callsign = qso.callsign?.toUpperCase(); if (!callsign) continue; const points = stationPoints.get(callsign); if (!points) continue; workedStations.add(callsign); if (qso.lotwQslRstatus === 'Y') { totalPoints += points; // For perQso mode, each QSO is its own slot with a qsos array containing just itself stationDetails.push({ qsoId: qso.id, callsign, points, worked: true, confirmed: true, qsoDate: qso.qsoDate, band: qso.band, mode: qso.mode, qsos: [{ qsoId: qso.id, callsign: qso.callsign, mode: qso.mode, qsoDate: qso.qsoDate, timeOn: qso.timeOn, band: qso.band, satName: qso.satName, confirmed: true, }], }); } } } logger.debug('Point-based award progress', { workedStations: workedStations.size, totalPoints, target, }); // Base result const result = { worked: workedStations.size, confirmed: stationDetails.filter((s) => s.confirmed).length, totalPoints, target: target || 0, percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0, workedEntities: Array.from(workedStations), confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign), }; // Add details if requested if (includeDetails) { // Convert stationDetails to entity format for breakdown const entities = stationDetails.map((detail) => { if (countMode === 'perBandMode') { return { qsoId: detail.qsoId, entity: `${detail.callsign}/${detail.band}/${detail.mode}`, entityId: null, entityName: `${detail.callsign} (${detail.band}/${detail.mode})`, points: detail.points, worked: detail.worked, confirmed: detail.confirmed, qsoDate: detail.qsoDate, band: detail.band, mode: detail.mode, callsign: detail.callsign, lotwQslRdate: detail.lotwQslRdate, qsos: detail.qsos || [], // All confirmed QSOs for this slot }; } else if (countMode === 'perStation') { return { qsoId: detail.qsoId, entity: detail.callsign, entityId: null, entityName: detail.callsign, points: detail.points, worked: detail.worked, confirmed: detail.confirmed, qsoDate: detail.qsoDate, band: detail.band, mode: detail.mode, callsign: detail.callsign, lotwQslRdate: detail.lotwQslRdate, qsos: detail.qsos || [], // All confirmed QSOs for this station }; } else { return { qsoId: detail.qsoId, entity: `${detail.callsign}-${detail.qsoDate}`, entityId: null, entityName: `${detail.callsign} on ${detail.qsoDate}`, points: detail.points, worked: detail.worked, confirmed: detail.confirmed, qsoDate: detail.qsoDate, band: detail.band, mode: detail.mode, callsign: detail.callsign, lotwQslRdate: detail.lotwQslRdate, qsos: detail.qsos || [], // All confirmed QSOs for this slot (just this one QSO) }; } }); result.award = { id: award.id, name: award.name, description: award.description, caption: award.caption, target: award.rules?.target || 0, }; result.entities = entities; result.total = entities.length; result.confirmed = entities.filter((e) => e.confirmed).length; } else { result.stationDetails = stationDetails; } return result; } /** * Get entity value from QSO based on entity type */ function getEntityValue(qso, entityType) { switch (entityType) { case 'dxcc': return qso.entityId; case 'state': return qso.state; case 'grid': // For VUCC, use first 4 characters of grid return qso.grid ? qso.grid.substring(0, 4) : null; case 'callsign': return qso.callsign; default: return null; } } /** * Apply filters to QSOs based on award rules */ function applyFilters(qsos, filters) { if (!filters || !filters.filters) { return qsos; } return qsos.filter((qso) => { if (filters.operator === 'AND') { return filters.filters.every((filter) => matchesFilter(qso, filter)); } else if (filters.operator === 'OR') { return filters.filters.some((filter) => matchesFilter(qso, filter)); } return true; }); } /** * Check if a QSO matches a filter */ function matchesFilter(qso, filter) { let value; // Special handling for satellite field if (filter.field === 'satellite') { // Check if it's a satellite QSO (has satName) value = qso.satName && qso.satName.length > 0; } else { value = qso[filter.field]; } switch (filter.operator) { case 'eq': return value === filter.value; case 'ne': return value !== filter.value; case 'in': return Array.isArray(filter.value) && filter.value.includes(value); case 'nin': return Array.isArray(filter.value) && !filter.value.includes(value); case 'contains': return value && typeof value === 'string' && value.toLowerCase().includes(filter.value.toLowerCase()); default: return true; } } /** * Get award progress with QSO details */ export async function getAwardProgressDetails(userId, awardId) { // Check cache first const cached = getCachedAwardProgress(userId, awardId); if (cached) { logger.debug(`Cache hit for award ${awardId}, user ${userId}`); return cached; } logger.debug(`Cache miss for award ${awardId}, user ${userId} - calculating...`); // Get award definition const definitions = loadAwardDefinitions(); const award = definitions.find((def) => def.id === awardId); if (!award) { return null; } // Calculate progress const progress = await calculateAwardProgress(userId, award); const result = { award: { id: award.id, name: award.name, description: award.description, caption: award.caption, category: award.category, }, ...progress, }; // Store in cache setCachedAwardProgress(userId, awardId, result); return result; } /** * Get detailed entity breakdown for an award */ export async function getAwardEntityBreakdown(userId, awardId) { const definitions = loadAwardDefinitions(); const award = definitions.find((def) => def.id === awardId); if (!award) { return null; } let { rules } = award; // Normalize rules inline if (rules.type === 'filtered' && rules.baseRule) { rules = { type: 'entity', entityType: rules.baseRule.entityType, target: rules.baseRule.target, displayField: rules.baseRule.displayField, filters: rules.filters, }; } else if (rules.type === 'counter') { rules = { type: 'entity', entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign', target: rules.target, displayField: rules.displayField, filters: rules.filters, }; } // Handle DOK-based awards - use the dedicated function if (rules.type === 'dok') { return await calculateDOKAwardProgress(userId, award, { includeDetails: true }); } // Handle point-based awards - use the unified function if (rules.type === 'points') { return await calculatePointsAwardProgress(userId, award, { includeDetails: true }); } // Get all QSOs for user const allQSOs = await db .select() .from(qsos) .where(eq(qsos.userId, userId)); // Apply filters const filteredQSOs = applyFilters(allQSOs, rules.filters); // Apply allowed_bands filter if present let finalQSOs = filteredQSOs; if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) { finalQSOs = filteredQSOs.filter(qso => { const band = qso.band; return rules.allowed_bands.includes(band); }); } // Apply satellite_only filter if present if (rules.satellite_only) { finalQSOs = finalQSOs.filter(qso => qso.satName); } // Group by (entity, band, mode) slot for entity awards // This allows showing multiple QSOs per entity on different bands/modes const slotMap = new Map(); // Key: "entity/band/mode" -> slot object for (const qso of finalQSOs) { const entity = getEntityValue(qso, rules.entityType); if (!entity) continue; const band = qso.band || 'Unknown'; const mode = qso.mode || 'Unknown'; const slotKey = `${entity}/${band}/${mode}`; // Determine what to display as the entity name (only on first create) let displayName = String(entity); if (rules.displayField) { let rawValue = qso[rules.displayField]; if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) { rawValue = rawValue.substring(0, 4); } displayName = String(rawValue || entity); } else { displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity); } if (!slotMap.has(slotKey)) { slotMap.set(slotKey, { entity, entityId: qso.entityId, entityName: displayName, band, mode, worked: false, confirmed: false, qsos: [], // Array of confirmed QSOs for this slot }); } const slotData = slotMap.get(slotKey); slotData.worked = true; // Check for LoTW confirmation and add to qsos array if (qso.lotwQslRstatus === 'Y') { if (!slotData.confirmed) { slotData.confirmed = true; } // Add this confirmed QSO to the qsos array slotData.qsos.push({ qsoId: qso.id, callsign: qso.callsign, mode: qso.mode, qsoDate: qso.qsoDate, timeOn: qso.timeOn, band: qso.band, satName: qso.satName, confirmed: true, }); } } return { award: { id: award.id, name: award.name, description: award.description, caption: award.caption, target: rules.target || 0, }, entities: Array.from(slotMap.values()), total: slotMap.size, confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length, }; }