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:
@@ -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
|
||||
|
||||
547
src/backend/services/awards-admin.service.js
Normal file
547
src/backend/services/awards-admin.service.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user