feat: implement award definition editor with safety validation

Add comprehensive admin-only UI for managing award definitions stored as JSON files.

Backend changes:
- Add awards-admin.service.js with file operations and validation
- Add clearAwardCache() function to invalidate in-memory cache after changes
- Add API routes: GET/POST/PUT/DELETE /api/admin/awards, POST /api/admin/awards/:id/test
- Support testing unsaved awards by passing award definition directly

Frontend changes:
- Add awards list view at /admin/awards
- Add create form at /admin/awards/create with safety checks for:
  - Impossible filter combinations (e.g., mode=CW AND mode=SSB)
  - Redundant filters and mode groups
  - Logical contradictions (e.g., satellite_only with HF-only bands)
  - Duplicate callsigns, empty mode groups, etc.
- Add edit form at /admin/awards/[id] with same validation
- Add FilterBuilder component for nested filter structures
- Add TestAwardModal with deep validation and test calculation
- Add Awards tab to admin dashboard

Safety validation includes:
- Schema validation (required fields, types, formats)
- Business rule validation (valid rule types, operators, bands, modes)
- Cross-field validation (filter contradictions, allowed_bands conflicts)
- Edge case detection (complex filters, impossible targets)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-23 08:16:28 +01:00
parent b9b6afedb8
commit bd89ea0855
11 changed files with 4951 additions and 1 deletions

View File

@@ -44,6 +44,14 @@ import {
getAwardProgressDetails,
getAwardEntityBreakdown,
} from './services/awards.service.js';
import {
getAllAwardDefinitions,
getAwardDefinition,
createAwardDefinition,
updateAwardDefinition,
deleteAwardDefinition,
testAwardCalculation,
} from './services/awards-admin.service.js';
import {
getAutoSyncSettings,
updateAutoSyncSettings,
@@ -1445,6 +1453,220 @@ const app = new Elysia()
}
})
/**
* ================================================================
* AWARD MANAGEMENT ROUTES (Admin Only)
* ================================================================
*/
/**
* GET /api/admin/awards
* Get all award definitions (admin only)
*/
.get('/api/admin/awards', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const awards = await getAllAwardDefinitions();
return {
success: true,
awards,
};
} catch (error) {
logger.error('Error fetching award definitions', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award definitions',
};
}
})
/**
* GET /api/admin/awards/:id
* Get a single award definition (admin only)
*/
.get('/api/admin/awards/:id', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await getAwardDefinition(params.id);
if (!award) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
award,
};
} catch (error) {
logger.error('Error fetching award definition', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award definition',
};
}
})
/**
* POST /api/admin/awards
* Create a new award definition (admin only)
*/
.post(
'/api/admin/awards',
async ({ user, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await createAwardDefinition(body);
return {
success: true,
award,
message: 'Award definition created successfully',
};
} catch (error) {
logger.error('Error creating award definition', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
id: t.String(),
name: t.String(),
description: t.String(),
caption: t.String(),
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
}),
}
)
/**
* PUT /api/admin/awards/:id
* Update an award definition (admin only)
*/
.put(
'/api/admin/awards/:id',
async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const award = await updateAwardDefinition(params.id, body);
return {
success: true,
award,
message: 'Award definition updated successfully',
};
} catch (error) {
logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
id: t.Optional(t.String()),
name: t.String(),
description: t.String(),
caption: t.String(),
category: t.String(),
rules: t.Any(),
modeGroups: t.Optional(t.Any()),
}),
}
)
/**
* DELETE /api/admin/awards/:id
* Delete an award definition (admin only)
*/
.delete('/api/admin/awards/:id', async ({ user, params, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const result = await deleteAwardDefinition(params.id);
return {
success: true,
...result,
message: 'Award definition deleted successfully',
};
} catch (error) {
logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
})
/**
* POST /api/admin/awards/:id/test
* Test award calculation (admin only)
*/
.post(
'/api/admin/awards/:id/test',
async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
// Use provided userId or admin's own account
const testUserId = body.userId || user.id;
const awardDefinition = body.awardDefinition || null;
const result = await testAwardCalculation(params.id, testUserId, awardDefinition);
return {
success: true,
...result,
};
} catch (error) {
logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
userId: t.Optional(t.Integer()),
awardDefinition: t.Optional(t.Any()),
}),
}
)
/**
* ================================================================
* AUTO-SYNC SETTINGS ROUTES

View File

@@ -0,0 +1,547 @@
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from 'fs';
import { join } from 'path';
import { logger } from '../config.js';
import { calculateAwardProgress, getAwardById, clearAwardCache } from './awards.service.js';
/**
* Awards Admin Service
* Manages award definition JSON files for admin operations
*/
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
// Valid entity types for entity rule type
const VALID_ENTITY_TYPES = ['dxcc', 'state', 'grid', 'callsign'];
// Valid rule types
const VALID_RULE_TYPES = ['entity', 'dok', 'points', 'filtered', 'counter'];
// Valid count modes for points rule type
const VALID_COUNT_MODES = ['perStation', 'perBandMode', 'perQso'];
// Valid filter operators
const VALID_FILTER_OPERATORS = ['eq', 'ne', 'in', 'nin', 'contains'];
// Valid filter fields
const VALID_FILTER_FIELDS = [
'band', 'mode', 'callsign', 'entity', 'entityId', 'state', 'grid', 'satName', 'satellite'
];
// Valid bands
const VALID_BANDS = [
'160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m',
'6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'
];
// Valid modes
const VALID_MODES = [
'CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9',
'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'
];
/**
* Load all award definitions with file metadata
*/
export async function getAllAwardDefinitions() {
const definitions = [];
try {
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);
// Add file metadata
definitions.push({
...definition,
_filename: file,
_filepath: filePath,
});
} catch (error) {
logger.warn('Failed to load award definition', { file, error: error.message });
}
}
} catch (error) {
logger.error('Error reading award definitions directory', { error: error.message });
}
return definitions;
}
/**
* Get a single award definition by ID
*/
export async function getAwardDefinition(id) {
const definitions = await getAllAwardDefinitions();
return definitions.find(def => def.id === id) || null;
}
/**
* Validate an award definition
* @returns {Object} { valid: boolean, errors: string[], warnings: string[] }
*/
export function validateAwardDefinition(definition, existingDefinitions = []) {
const errors = [];
const warnings = [];
// Check required top-level fields
const requiredFields = ['id', 'name', 'description', 'caption', 'category', 'rules'];
for (const field of requiredFields) {
if (!definition[field]) {
errors.push(`Missing required field: ${field}`);
}
}
// Validate ID
if (definition.id) {
if (typeof definition.id !== 'string') {
errors.push('ID must be a string');
} else if (!/^[a-z0-9-]+$/.test(definition.id)) {
errors.push('ID must contain only lowercase letters, numbers, and hyphens');
} else {
// Check for duplicate ID (unless updating existing award)
const existingIds = existingDefinitions.map(d => d.id);
const isUpdate = existingDefinitions.find(d => d.id === definition.id);
const duplicates = existingDefinitions.filter(d => d.id === definition.id);
if (duplicates.length > 1 || (duplicates.length === 1 && !isUpdate)) {
errors.push(`Award ID "${definition.id}" already exists`);
}
}
}
// Validate name
if (definition.name && typeof definition.name !== 'string') {
errors.push('Name must be a string');
}
// Validate description
if (definition.description && typeof definition.description !== 'string') {
errors.push('Description must be a string');
}
// Validate caption
if (definition.caption && typeof definition.caption !== 'string') {
errors.push('Caption must be a string');
}
// Validate category
if (definition.category && typeof definition.category !== 'string') {
errors.push('Category must be a string');
}
// Validate modeGroups if present
if (definition.modeGroups) {
if (typeof definition.modeGroups !== 'object') {
errors.push('modeGroups must be an object');
} else {
for (const [groupName, modes] of Object.entries(definition.modeGroups)) {
if (!Array.isArray(modes)) {
errors.push(`modeGroups "${groupName}" must be an array of mode strings`);
} else {
for (const mode of modes) {
if (typeof mode !== 'string') {
errors.push(`mode "${mode}" in group "${groupName}" must be a string`);
} else if (!VALID_MODES.includes(mode)) {
warnings.push(`Unknown mode "${mode}" in group "${groupName}"`);
}
}
}
}
}
}
// Validate rules
if (!definition.rules) {
errors.push('Rules object is required');
} else if (typeof definition.rules !== 'object') {
errors.push('Rules must be an object');
} else {
const ruleValidation = validateRules(definition.rules);
errors.push(...ruleValidation.errors);
warnings.push(...ruleValidation.warnings);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate rules object
*/
function validateRules(rules) {
const errors = [];
const warnings = [];
// Check rule type
if (!rules.type) {
errors.push('Rules must have a type');
} else if (!VALID_RULE_TYPES.includes(rules.type)) {
errors.push(`Invalid rule type: ${rules.type}. Must be one of: ${VALID_RULE_TYPES.join(', ')}`);
}
// Validate based on rule type
switch (rules.type) {
case 'entity':
validateEntityRule(rules, errors, warnings);
break;
case 'dok':
validateDOKRule(rules, errors, warnings);
break;
case 'points':
validatePointsRule(rules, errors, warnings);
break;
case 'filtered':
validateFilteredRule(rules, errors, warnings);
break;
case 'counter':
validateCounterRule(rules, errors, warnings);
break;
}
// Validate filters if present
if (rules.filters) {
const filterValidation = validateFilters(rules.filters);
errors.push(...filterValidation.errors);
warnings.push(...filterValidation.warnings);
}
return { errors, warnings };
}
/**
* Validate entity rule
*/
function validateEntityRule(rules, errors, warnings) {
if (!rules.entityType) {
errors.push('Entity rule requires entityType');
} else if (!VALID_ENTITY_TYPES.includes(rules.entityType)) {
errors.push(`Invalid entityType: ${rules.entityType}. Must be one of: ${VALID_ENTITY_TYPES.join(', ')}`);
}
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('Entity rule requires a positive target number');
}
if (rules.allowed_bands) {
if (!Array.isArray(rules.allowed_bands)) {
errors.push('allowed_bands must be an array');
} else {
for (const band of rules.allowed_bands) {
if (!VALID_BANDS.includes(band)) {
warnings.push(`Unknown band in allowed_bands: ${band}`);
}
}
}
}
if (rules.satellite_only !== undefined && typeof rules.satellite_only !== 'boolean') {
errors.push('satellite_only must be a boolean');
}
if (rules.displayField && typeof rules.displayField !== 'string') {
errors.push('displayField must be a string');
}
}
/**
* Validate DOK rule
*/
function validateDOKRule(rules, errors, warnings) {
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('DOK rule requires a positive target number');
}
if (rules.confirmationType && rules.confirmationType !== 'dcl') {
warnings.push('DOK rule confirmationType should be "dcl"');
}
if (rules.displayField && typeof rules.displayField !== 'string') {
errors.push('displayField must be a string');
}
}
/**
* Validate points rule
*/
function validatePointsRule(rules, errors, warnings) {
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('Points rule requires a positive target number');
}
if (!rules.stations || !Array.isArray(rules.stations)) {
errors.push('Points rule requires a stations array');
} else if (rules.stations.length === 0) {
errors.push('Points rule stations array cannot be empty');
} else {
for (let i = 0; i < rules.stations.length; i++) {
const station = rules.stations[i];
if (!station.callsign || typeof station.callsign !== 'string') {
errors.push(`Station ${i + 1} missing callsign`);
}
if (typeof station.points !== 'number' || station.points <= 0) {
errors.push(`Station ${i + 1} must have positive points value`);
}
}
}
if (rules.countMode && !VALID_COUNT_MODES.includes(rules.countMode)) {
errors.push(`Invalid countMode: ${rules.countMode}. Must be one of: ${VALID_COUNT_MODES.join(', ')}`);
}
}
/**
* Validate filtered rule
*/
function validateFilteredRule(rules, errors, warnings) {
if (!rules.baseRule) {
errors.push('Filtered rule requires baseRule');
} else {
// Recursively validate base rule
const baseValidation = validateRules(rules.baseRule);
errors.push(...baseValidation.errors);
warnings.push(...baseValidation.warnings);
}
if (!rules.filters) {
warnings.push('Filtered rule has no filters - baseRule will be used as-is');
}
}
/**
* Validate counter rule
*/
function validateCounterRule(rules, errors, warnings) {
if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) {
errors.push('Counter rule requires a positive target number');
}
if (!rules.countBy) {
errors.push('Counter rule requires countBy');
} else if (!['qso', 'callsign'].includes(rules.countBy)) {
errors.push(`Invalid countBy: ${rules.countBy}. Must be one of: qso, callsign`);
}
if (rules.displayField && typeof rules.displayField !== 'string') {
errors.push('displayField must be a string');
}
}
/**
* Validate filters object
*/
function validateFilters(filters, depth = 0) {
const errors = [];
const warnings = [];
if (!filters) {
return { errors, warnings };
}
// Prevent infinite recursion
if (depth > 10) {
errors.push('Filters are too deeply nested (maximum 10 levels)');
return { errors, warnings };
}
if (filters.operator && !['AND', 'OR'].includes(filters.operator)) {
errors.push(`Invalid filter operator: ${filters.operator}. Must be AND or OR`);
}
if (filters.filters) {
if (!Array.isArray(filters.filters)) {
errors.push('Filters must be an array');
} else {
for (const filter of filters.filters) {
if (filter.filters) {
// Nested filter group
const nestedValidation = validateFilters(filter, depth + 1);
errors.push(...nestedValidation.errors);
warnings.push(...nestedValidation.warnings);
} else {
// Leaf filter
if (!filter.field) {
errors.push('Filter missing field');
} else if (!VALID_FILTER_FIELDS.includes(filter.field)) {
warnings.push(`Unknown filter field: ${filter.field}`);
}
if (!filter.operator) {
errors.push('Filter missing operator');
} else if (!VALID_FILTER_OPERATORS.includes(filter.operator)) {
errors.push(`Invalid filter operator: ${filter.operator}`);
}
if (filter.value === undefined) {
errors.push('Filter missing value');
}
if (['in', 'nin'].includes(filter.operator) && !Array.isArray(filter.value)) {
errors.push(`Filter operator ${filter.operator} requires an array value`);
}
}
}
}
}
return { errors, warnings };
}
/**
* Create a new award definition
*/
export async function createAwardDefinition(definition) {
// Get all existing definitions for duplicate check
const existing = await getAllAwardDefinitions();
// Validate the definition
const validation = validateAwardDefinition(definition, existing);
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
}
// Create filename from ID
const filename = `${definition.id}.json`;
const filepath = join(AWARD_DEFINITIONS_DIR, filename);
// Check if file already exists
if (existsSync(filepath)) {
throw new Error(`Award file "${filename}" already exists`);
}
// Remove metadata fields before saving
const { _filename, _filepath, ...cleanDefinition } = definition;
// Write to file
writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8');
// Clear the cache so new award is immediately available
clearAwardCache();
logger.info('Created award definition', { id: definition.id, filename });
return {
...cleanDefinition,
_filename: filename,
_filepath: filepath,
};
}
/**
* Update an existing award definition
*/
export async function updateAwardDefinition(id, updatedDefinition) {
// Get existing definition
const existing = await getAwardDefinition(id);
if (!existing) {
throw new Error(`Award "${id}" not found`);
}
// Ensure ID matches
if (updatedDefinition.id && updatedDefinition.id !== id) {
throw new Error('Cannot change award ID');
}
// Set the ID from the parameter
updatedDefinition.id = id;
// Get all definitions for validation
const allDefinitions = await getAllAwardDefinitions();
// Validate the updated definition
const validation = validateAwardDefinition(updatedDefinition, allDefinitions);
if (!validation.valid) {
throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
}
// Keep the same filename
const filename = existing._filename;
const filepath = existing._filepath;
// Remove metadata fields before saving
const { _filename, _filepath, ...cleanDefinition } = updatedDefinition;
// Write to file
writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8');
// Clear the cache so updated award is immediately available
clearAwardCache();
logger.info('Updated award definition', { id, filename });
return {
...cleanDefinition,
_filename: filename,
_filepath: filepath,
};
}
/**
* Delete an award definition
*/
export async function deleteAwardDefinition(id) {
const existing = await getAwardDefinition(id);
if (!existing) {
throw new Error(`Award "${id}" not found`);
}
// Delete the file
unlinkSync(existing._filepath);
// Clear the cache so deleted award is immediately removed
clearAwardCache();
logger.info('Deleted award definition', { id, filename: existing._filename });
return { success: true, id };
}
/**
* Test award calculation for a user
* @param {string} id - Award ID (must exist unless awardDefinition is provided)
* @param {number} userId - User ID to test with
* @param {Object} awardDefinition - Optional award definition (for testing unsaved awards)
*/
export async function testAwardCalculation(id, userId, awardDefinition = null) {
// Get award definition - either from parameter or from cache
let award = awardDefinition;
if (!award) {
award = getAwardById(id);
if (!award) {
throw new Error(`Award "${id}" not found`);
}
}
// Calculate progress
const progress = await calculateAwardProgress(userId, award);
// Warn if no matches
const warnings = [];
if (progress.worked === 0 && progress.confirmed === 0) {
warnings.push('No QSOs matched the award criteria. Check filters and band/mode restrictions.');
}
// Get sample entities
const sampleEntities = (progress.confirmedEntities || []).slice(0, 10);
return {
award: {
id: award.id,
name: award.name,
description: award.description,
},
worked: progress.worked,
confirmed: progress.confirmed,
target: progress.target,
percentage: progress.percentage,
sampleEntities,
warnings,
};
}

View File

@@ -53,6 +53,15 @@ function loadAwardDefinitions() {
return definitions;
}
/**
* Clear the cached award definitions
* Call this after creating, updating, or deleting award definitions
*/
export function clearAwardCache() {
cachedAwardDefinitions = null;
logger.info('Award cache cleared');
}
/**
* Get all available awards
*/