import { db } from '../config/database.js'; import { qsos } from '../db/schema/index.js'; import { eq, and, or, desc, sql } from 'drizzle-orm'; import logger from '../config/logger.js'; import { readFileSync } from 'fs'; import { join } from 'path'; /** * Awards Service * Calculates award progress based on QSO data */ // Load award definitions from files const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions'); /** * Load all award definitions */ function loadAwardDefinitions() { const definitions = []; try { const files = [ 'dxcc.json', 'dxcc-cw.json', 'was.json', 'vucc-sat.json', 'sat-rs44.json', ]; 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 }); } 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, category: def.category, rules: def.rules, })); } /** * Normalize award rules to a consistent format */ function normalizeAwardRules(rules) { // Handle "filtered" type awards (like DXCC CW) if (rules.type === 'filtered' && rules.baseRule) { return { type: 'entity', entityType: rules.baseRule.entityType, target: rules.baseRule.target, filters: rules.filters, }; } // Handle "counter" type awards (like RS-44) // These count unique callsigns instead of entities if (rules.type === 'counter') { return { type: 'entity', entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign', target: rules.target, filters: rules.filters, }; } return rules; } /** * Calculate award progress for a user * @param {number} userId - User ID * @param {Object} award - Award definition */ export async function calculateAwardProgress(userId, award) { let { rules } = award; // Normalize rules to handle different formats rules = normalizeAwardRules(rules); logger.debug('Calculating award progress', { userId, awardId: award.id, awardType: rules.type, entityType: rules.entityType, hasFilters: !!rules.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 (rules.filters) { filteredQSOs = applyFilters(allQSOs, rules.filters); logger.debug('QSOs after filters', { count: filteredQSOs.length }); } // Calculate worked and confirmed entities const workedEntities = new Set(); const confirmedEntities = new Set(); for (const qso of filteredQSOs) { 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), }; } /** * 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) { // Get award definition const definitions = loadAwardDefinitions(); const award = definitions.find((def) => def.id === awardId); if (!award) { throw new Error('Award not found'); } // Calculate progress const progress = await calculateAwardProgress(userId, award); return { award: { id: award.id, name: award.name, description: award.description, category: award.category, }, ...progress, }; } /** * 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) { throw new Error('Award not found'); } let { rules } = award; // Normalize rules to handle different formats rules = normalizeAwardRules(rules); // 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); // Group by entity const entityMap = new Map(); for (const qso of filteredQSOs) { const entity = getEntityValue(qso, rules.entityType); if (!entity) continue; if (!entityMap.has(entity)) { entityMap.set(entity, { entity, entityId: qso.entityId, worked: false, confirmed: false, qsoDate: qso.qsoDate, band: qso.band, mode: qso.mode, callsign: qso.callsign, }); } const entityData = entityMap.get(entity); entityData.worked = true; if (qso.lotwQslRstatus === 'Y') { entityData.confirmed = true; entityData.lotwQslRdate = qso.lotwQslRdate; } } return { award: { id: award.id, name: award.name, description: award.description, }, entities: Array.from(entityMap.values()), total: entityMap.size, confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length, }; }