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