Files
award/src/backend/services/awards.service.js
Joerg 3b7dfd74fe Display entity names instead of IDs in award details
- Add entityName field to backend entity breakdown
- For DXCC: Show country name (e.g., 'Germany') with ID in parentheses
- For WAS: Show state name
- For VUCC: Show grid square
- For RS-44: Show callsign
- Update sorting to use entity names for better UX
- Add subtle styling for entity IDs (gray, smaller font)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 09:05:50 +01:00

313 lines
7.5 KiB
JavaScript

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,
entityName: qso.entity || qso.state || qso.grid || qso.callsign || String(entity),
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,
};
}