diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 3dcbb90..3843a4a 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -36,7 +36,7 @@ The Ham Radio Award Portal is a full-stack web application designed to help amat ### System Architecture ``` -┌─────────────────┐ HTTP/REST ┌─────────────────┐ +┌─────────────────┐ HTTP/REST ┌─────────────────┐ │ │ ◄──────────────────► │ │ │ SvelteKit │ │ ElysiaJS │ │ Frontend │ │ Backend │ diff --git a/src/backend/index.js b/src/backend/index.js index b2e6f71..e6fbb65 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -20,6 +20,11 @@ import { getUserActiveJob, getUserJobs, } from './services/job-queue.service.js'; +import { + getAllAwards, + getAwardProgressDetails, + getAwardEntityBreakdown, +} from './services/awards.service.js'; /** * Main backend application @@ -478,6 +483,89 @@ const app = new Elysia() } }) + /** + * GET /api/awards + * Get all available awards (requires authentication) + */ + .get('/api/awards', async ({ user, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const awards = await getAllAwards(); + + return { + success: true, + awards, + }; + } catch (error) { + logger.error('Error fetching awards', { error: error.message }); + set.status = 500; + return { + success: false, + error: 'Failed to fetch awards', + }; + } + }) + + /** + * GET /api/awards/:awardId/progress + * Get award progress for user (requires authentication) + */ + .get('/api/awards/:awardId/progress', async ({ user, params, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const { awardId } = params; + const progress = await getAwardProgressDetails(user.id, awardId); + + return { + success: true, + ...progress, + }; + } catch (error) { + logger.error('Error calculating award progress', { error: error.message }); + set.status = 500; + return { + success: false, + error: error.message || 'Failed to calculate award progress', + }; + } + }) + + /** + * GET /api/awards/:awardId/entities + * Get detailed entity breakdown for an award (requires authentication) + */ + .get('/api/awards/:awardId/entities', async ({ user, params, set }) => { + if (!user) { + set.status = 401; + return { success: false, error: 'Unauthorized' }; + } + + try { + const { awardId } = params; + const breakdown = await getAwardEntityBreakdown(user.id, awardId); + + return { + success: true, + ...breakdown, + }; + } catch (error) { + logger.error('Error fetching award entities', { error: error.message }); + set.status = 500; + return { + success: false, + error: error.message || 'Failed to fetch award entities', + }; + } + }) + // Health check endpoint .get('/api/health', () => ({ status: 'ok', diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js new file mode 100644 index 0000000..aeab064 --- /dev/null +++ b/src/backend/services/awards.service.js @@ -0,0 +1,258 @@ +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, + })); +} + +/** + * Calculate award progress for a user + * @param {number} userId - User ID + * @param {Object} award - Award definition + */ +export async function calculateAwardProgress(userId, award) { + const { rules } = award; + + // Get all QSOs for user + const allQSOs = await db + .select() + .from(qsos) + .where(eq(qsos.userId, userId)); + + // Apply filters if defined + let filteredQSOs = allQSOs; + if (rules.filters) { + filteredQSOs = applyFilters(allQSOs, rules.filters); + } + + // 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) { + const 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.includes(filter.value); + 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'); + } + + const { rules } = award; + + // 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, + }; +} diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index fbe91e9..42dc53a 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -18,6 +18,7 @@