Files
award/src/backend/services/awards.service.js
Joerg cce520a00e chore: code cleanup - remove duplicates and add caching
- Delete duplicate getCacheStats() function in cache.service.js
- Fix date calculation bug in lotw.service.js (was Date.now()-Date.now())
- Extract duplicate helper functions (yieldToEventLoop, getQSOKey) to sync-helpers.js
- Cache award definitions in memory to avoid repeated file I/O
- Delete unused parseDCLJSONResponse() function
- Remove unused imports (getPerformanceSummary, resetPerformanceMetrics)
- Auto-discover award JSON files instead of hardcoded list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 10:22:00 +01:00

816 lines
23 KiB
JavaScript

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