Compare commits

..

23 Commits

Author SHA1 Message Date
b296514356 fix: use entityId for QSO stats entity counting to match DXCC award
Changed QSO page statistics to count entities using entity_id (numeric
DXCC code) instead of entity (text field) for consistency with DXCC
award progress calculations.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 18:33:54 +01:00
70858836d0 Spinner 2026-01-24 16:01:05 +01:00
257ebf6c5d fix: increase page max-width from 1200px to 1600px for better table display
Tables were too narrow and required horizontal scrolling on larger screens.
Increased max-width across all pages to better utilize available screen space.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:56:56 +01:00
caf7703073 fix: add missing updateUserRole import in admin service
The updateUserRole function was being called but not imported from
auth.service.js, causing "updateUserRole is not defined" error when
changing user roles via admin interface.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:44:52 +01:00
fa6420d149 fix: use CSS variables for dark mode support in settings page
Replace hard-coded colors with CSS variables from the theme system to
properly support both light and dark modes. Also add proper input
styling and strong tag emphasis colors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:57:50 +01:00
aa55158347 feat: add WAE (Worked All Europe) award implementation
Implement DARC's WAE award with dual metrics tracking (countries + bandpoints).

Features:
- 54 European countries with correct DXCC entityIds from ARRL
- 8 WAE-specific entities (Shetland, Sicily, Sardinia, Crete, etc.)
- Bandpoints calculation: 1 pt/band (2 pts for 160m/80m), max 5 bands/country
- 5 award levels: WAE III (40/100), WAE II (50/150), WAE I (60/200),
  WAE TOP (70/300), WAE Trophy (all/365)
- Mode groups: CW, SSB, RTTY, FT8, Digi-Modes, Mixed-Mode
- Admin UI support for creating/editing WAE awards
- Award detail page with dual metrics display

Files:
- award-data/wae-country-list.json: WAE country definitions
- award-definitions/wae.json: Award configuration
- src/backend/services/awards.service.js: WAE calculation functions
- src/frontend: Admin and award detail views

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 18:07:52 +01:00
joerg
e4e7f3c208 Awawrdd 2026-01-23 14:18:56 +01:00
a35731f626 fix: use smart default for displayField based on entityType
When displayField is omitted in award definitions, the backend now
selects the appropriate default field based on the entityType:
- dxcc → entity (country name)
- state → state
- grid → grid (4-character)
- callsign → callsign

Previously, it used a fixed fallback order that prioritized entity
over other fields, causing grid-based awards to show DXCC names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 14:14:05 +01:00
2ae47232cb fix: improve dark mode contrast for Points and Target badges in award detail view
Replace inline styles with CSS classes that use semi-transparent
backgrounds in dark mode instead of bright solid colors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 14:07:23 +01:00
8b846bffbe docs: add super-admin role documentation
Add comprehensive documentation for the new super-admin role feature:

README.md:
- Update Users Table schema with isAdmin, isSuperAdmin, lastSeen fields
- Add Admin API section with all endpoints
- Add User Roles and Permissions section with security rules

docs/DOCUMENTATION.md:
- Update Users Table schema
- Add Admin System section with overview, roles, security rules
- Document all admin API endpoints
- Add audit logging details
- Include JWT token structure
- Add setup and deployment instructions

CLAUDE.md:
- Add Admin System and User Roles section
- Document admin service functions
- Include security rules
- Add JWT token claims structure
- Document frontend admin interface

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 13:53:48 +01:00
ed433902d9 feat: add super-admin role with admin impersonation support
Add a new super-admin role that can impersonate other admins. Regular
admins retain all existing permissions but cannot impersonate other
admins or promote users to super-admin.

Backend changes:
- Add isSuperAdmin field to users table with default false
- Add isSuperAdmin() check function to auth service
- Update JWT tokens to include isSuperAdmin claim
- Allow super-admins to impersonate other admins
- Add security rules for super-admin role changes

Frontend changes:
- Display "Super Admin" badge with gradient styling
- Add "Super Admin" option to role change modal
- Enable impersonate button for super-admins targeting admins
- Add "Super Admins Only" filter option

Security rules:
- Only super-admins can promote/demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 13:32:55 +01:00
a5f0e3b96f fix: include Alaska and Hawaii DXCC entities in WAS award
WAS award was only counting states in DXCC entity 291 (United States),
which excluded Alaska (DXCC 6) and Hawaii (DXCC 110). Updated filter to
use "in" operator with all three relevant DXCC entity IDs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 12:47:09 +01:00
b09e2b3ea2 feat: add achievements system to awards with mode filter support
Implement achievements milestone feature for awards with configurable
levels (Silver, Gold, Platinum, etc.) that track progress beyond the
base award target. Achievements display earned badges and progress bar
toward next level.

Backend:
- Add calculateAchievementProgress() helper in awards.service.js
- Include achievements field in getAllAwards() and getAwardById()
- Add achievements validation in awards-admin.service.js
- Update PUT endpoint validation schema to include achievements field

Frontend:
- Add achievements section to award detail page with gold badges
- Add reactive achievement progress calculation that respects mode filter
- Add achievements tab to admin create/edit pages with full CRUD

Award Definitions:
- Add achievements to DXCC (100/200/300/500)
- Add achievements to DLD (50/100/200/300)
- Add achievements to WAS (30/40/50)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 12:42:32 +01:00
239963ed89 feat: implement theme switching system with light and dark modes
Add complete theme switching system supporting Light Mode, Dark Mode, and
System preference (follows OS setting). Uses CSS custom properties for all
colors and Svelte store for state management with localStorage persistence.

New files:
- src/frontend/src/lib/stores/theme.js: Theme state management store
- src/frontend/src/app.css: CSS variables for light/dark themes
- src/frontend/src/lib/components/ThemeSwitcher.svelte: Theme switcher UI

Updated all components to use CSS variables instead of hardcoded colors:
- Main pages (home, awards, QSOs, settings, auth)
- Admin dashboard and award management pages
- Shared components (BackButton, ErrorDisplay, Loading)

Features:
- localStorage persistence for user preference
- System preference detection via matchMedia API
- FOUC prevention with inline script in app.html
- Smooth theme transitions across entire application

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 11:46:41 +01:00
d1e4c39ad6 feat: add last_seen tracking for users
Adds last_seen field to track when users last accessed the tool:
- Add lastSeen column to users table schema (nullable timestamp)
- Create migration to add last_seen column to existing databases
- Add updateLastSeen() function to auth.service.js
- Update auth derive middleware to update last_seen on each authenticated request (async, non-blocking)
- Add lastSeen to admin getUserStats() query for display in admin users table
- Add "Last Seen" column to admin users table in frontend

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:57:45 +01:00
24e0e3bfdb fix: correct target and needed calculation in award detail view
Fixes incorrect calculation of target and needed values in award detail page:
- Changed award.target to award.rules?.target (correct path in JSON structure)
- Added missing Target display card for non-points awards (DXCC, DLD, etc.)
- Added null checks to prevent broken calculations for awards without targets

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:33:42 +01:00
36453c8922 fix: resolve stations editor reactivity issue in award admin
Use Svelte stores with value/on:input instead of bind:value for
stations array to properly track nested property changes. Add invisible
reactivity triggers to force Svelte to track station callsign/points properties.

Also update award sorting to handle numeric prefixes in numerical order
(44 before 73) and update RS-44 satellite award category.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 09:27:08 +01:00
bd89ea0855 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>
2026-01-23 08:16:28 +01:00
b9b6afedb8 docs: add modeGroups feature to award system specification
Document the modeGroups property for award definitions, which allows
creating convenient multi-mode filters in the award detail view.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:27:29 +01:00
85d171adc8 docs: document mode groups feature in CLAUDE.md and README.md
Add documentation for the new configurable mode groups feature:
- CLAUDE.md: Add modeGroups to Award Rule Options section
- CLAUDE.md: Update Award Detail View section with mode group info
- CLAUDE.md: Add to Recent Development Work (January 2026)
- README.md: Add GET /api/awards/:awardId endpoint
- README.md: Add new Mode Groups section in Features in Detail

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:19:49 +01:00
cd361115fe feat: add configurable mode groups to award detail view
Add per-award configurable mode groups for filtering multiple modes
together in the award detail view. Mode groups are displayed with
visual separators in the mode filter dropdown.

Backend changes:
- Add modeGroups to getAllAwards() return mapping
- Add getAwardById() function to fetch single award definition
- Add GET /api/awards/:awardId endpoint

Frontend changes:
- Fetch award definition separately to get modeGroups
- Update availableModes to include mode groups with separator
- Update filteredEntities logic to handle mode groups
- Update groupDataForTable() and applyFilter() for mode groups
- Disable separator option in dropdown

Award definitions:
- DXCC: Add Digi-Modes, Classic Digi-Modes, Mixed-Mode w/o WSJT-Modes,
  Phone-Modes groups
- DLD: Add same mode groups (adjusted for available modes)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 07:06:12 +01:00
69b33720b3 fix: prevent infinite retry loop for auto-sync users without credentials
When auto-sync is enabled but credentials (LoTW/DCL) are removed, the
scheduler would continuously try to sync every minute, logging the same
warning forever.

Now:
- Split pending users into those with and without credentials
- For users without credentials, update nextSyncAt to retry in 24 hours
- Log a warning with affected user IDs
- Only return users with valid credentials for job processing

This prevents log spam and unnecessary database queries while still
periodically checking if credentials have been restored.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 13:17:16 +01:00
648cf2c5a5 feat: implement auto-sync scheduler for LoTW and DCL
Add automatic synchronization scheduler that allows users to configure
periodic sync intervals for LoTW and DCL via the settings page.

Features:
- Users can enable/disable auto-sync per service (LoTW/DCL)
- Configurable sync intervals (1-720 hours)
- Settings page UI for managing auto-sync preferences
- Dashboard shows upcoming scheduled auto-sync jobs
- Scheduler runs every minute, triggers syncs when due
- Survives server restarts via database persistence
- Graceful shutdown support (SIGINT/SIGTERM)

Backend:
- New autoSyncSettings table with user preferences
- auto-sync.service.js for CRUD operations and scheduling logic
- scheduler.service.js for periodic tick processing
- API endpoints: GET/PUT /auto-sync/settings, GET /auto-sync/scheduler/status

Frontend:
- Auto-sync settings section in settings page
- Upcoming auto-sync section on dashboard with scheduled job cards
- Purple-themed UI for scheduled jobs with countdown animation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 12:40:55 +01:00
50 changed files with 11123 additions and 562 deletions

106
CLAUDE.md
View File

@@ -362,6 +362,23 @@ To add a new award:
- If `true`, only QSOs with `satName` field set are counted - If `true`, only QSOs with `satName` field set are counted
- Used for DXCC SAT award - Used for DXCC SAT award
**modeGroups**: Define mode groups for filtering in award detail view
```json
{
"modeGroups": {
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
"Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
}
}
```
- Optional field at award definition level (not in `rules`)
- Key is the display name shown in the mode filter dropdown
- Value is an array of mode strings to include in the group
- Used to create convenient mode filters that combine multiple modes
- Awards without `modeGroups` work as before (backward compatible)
**filters**: Additional filtering options **filters**: Additional filtering options
- `eq`: equals - `eq`: equals
- `ne`: not equals - `ne`: not equals
@@ -443,7 +460,10 @@ The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced fil
**Key Features**: **Key Features**:
- **Summary Cards**: Show total, confirmed, worked, needed counts for unique entities - **Summary Cards**: Show total, confirmed, worked, needed counts for unique entities
- **Mode Filter**: Filter by specific mode or view "Mixed Mode" (aggregates all modes by band) - **Mode Filter**: Filter by specific mode, mode group, or view "Mixed Mode" (aggregates all modes by band)
- Awards can define `modeGroups` to create convenient multi-mode filters
- Example groups: "Digi-Modes", "Classic Digi-Modes", "Phone-Modes", "Mixed-Mode w/o WSJT-Modes"
- Visual separator (`─────`) appears between mode groups and individual modes
- **Table Columns**: Show bands (or band/mode combinations) as columns - **Table Columns**: Show bands (or band/mode combinations) as columns
- **QSO Counts**: Each cell shows count of confirmed QSOs for that (entity, band, mode) slot - **QSO Counts**: Each cell shows count of confirmed QSOs for that (entity, band, mode) slot
- **Drill-Down**: Click a count to open modal showing all QSOs for that slot - **Drill-Down**: Click a count to open modal showing all QSOs for that slot
@@ -456,6 +476,8 @@ The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced fil
**Column Sums**: Show unique entity count per column (not QSO counts) **Column Sums**: Show unique entity count per column (not QSO counts)
**Backend Changes** (`src/backend/services/awards.service.js`): **Backend Changes** (`src/backend/services/awards.service.js`):
- `getAllAwards()`: Returns award definitions including `modeGroups`
- `getAwardById(awardId)`: Returns single award definition with `modeGroups`
- `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects QSOs in `qsos` array - `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects QSOs in `qsos` array
- `calculatePointsAwardProgress()`: Handles all count modes with `qsos` array - `calculatePointsAwardProgress()`: Handles all count modes with `qsos` array
- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots - `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots
@@ -511,6 +533,10 @@ const params = new URLSearchParams({
- DXCC restricted to HF bands (160m-10m) only - DXCC restricted to HF bands (160m-10m) only
- Added DXCC SAT award for satellite-only QSOs - Added DXCC SAT award for satellite-only QSOs
- Removed redundant award variants (DXCC CW, DLD variants) - Removed redundant award variants (DXCC CW, DLD variants)
- Added `modeGroups` for configurable multi-mode filters in award detail view
- Per-award configuration of mode groups (Digi-Modes, Phone-Modes, etc.)
- Visual separator in mode filter dropdown between groups and individual modes
- DXCC and DLD awards include: Digi-Modes, Classic Digi-Modes, Mixed-Mode w/o WSJT-Modes, Phone-Modes
**Award Detail View Improvements**: **Award Detail View Improvements**:
- Summary shows unique entity progress instead of QSO counts - Summary shows unique entity progress instead of QSO counts
@@ -518,7 +544,85 @@ const params = new URLSearchParams({
- Satellite QSOs grouped under "SAT" column - Satellite QSOs grouped under "SAT" column
- Bands sorted by wavelength instead of alphabetically - Bands sorted by wavelength instead of alphabetically
- Mode removed from table headers (visible in filter dropdown) - Mode removed from table headers (visible in filter dropdown)
- Mode groups allow filtering multiple modes together (e.g., all digital modes)
**Backend API Additions**:
- Added `GET /api/awards/:awardId` endpoint for fetching single award definition
- `getAllAwards()` now includes `modeGroups` field
**QSO Management**: **QSO Management**:
- Fixed DELETE /api/qsos/all to handle foreign key constraints - Fixed DELETE /api/qsos/all to handle foreign key constraints
- Added cache invalidation after QSO deletion - Added cache invalidation after QSO deletion
### Admin System and User Roles
The application supports three user roles with different permission levels:
**User Roles**:
- **Regular User**: View own QSOs, sync from LoTW/DCL, track award progress
- **Admin**: All user permissions + view system stats + manage users + impersonate regular users
- **Super Admin**: All admin permissions + promote/demote admins + impersonate admins
**Database Schema** (`src/backend/db/schema/index.js`):
- `isAdmin`: Boolean flag for admin users (default: false)
- `isSuperAdmin`: Boolean flag for super-admin users (default: false)
**Admin Service** (`src/backend/services/admin.service.js`):
- `isAdmin(userId)`: Check if user is admin
- `isSuperAdmin(userId)`: Check if user is super-admin
- `changeUserRole(adminId, targetUserId, newRole)`: Change user role ('user', 'admin', 'super-admin')
- `impersonateUser(adminId, targetUserId)`: Start impersonating a user
- `verifyImpersonation(token)`: Verify impersonation token validity
- `stopImpersonation(adminId, targetUserId)`: Stop impersonation
- `logAdminAction(adminId, actionType, targetUserId, details)`: Log admin actions
**Security Rules**:
1. Only super-admins can promote/demote super-admins
2. Regular admins cannot promote users to super-admin
3. Super-admins cannot demote themselves (prevents lockout)
4. Cannot demote the last super-admin
5. Regular admins can only impersonate regular users
6. Super-admins can impersonate any user (including other admins)
**Backend API Routes** (`src/backend/index.js`):
- `POST /api/admin/users/:userId/role`: Change user role
- Body: `{ "role": "user" | "admin" | "super-admin" }`
- `POST /api/admin/impersonate/:userId`: Start impersonating
- `POST /api/admin/impersonate/stop`: Stop impersonating
- `GET /api/admin/impersonation/status`: Check impersonation status
- `GET /api/admin/stats`: System statistics
- `GET /api/admin/users`: List all users
- `GET /api/admin/actions`: Admin action log
- `DELETE /api/admin/users/:userId`: Delete user
**JWT Token Claims**:
```javascript
{
userId: number,
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean, // Super-admin flag
impersonatedBy: number, // Present when impersonating
exp: number
}
```
**Frontend Admin Page** (`src/frontend/src/routes/admin/+page.svelte`):
- System statistics dashboard
- User management with filtering (all, super-admin, admin, user)
- Role change modal (user → admin → super-admin)
- Impersonate button (enabled for super-admins targeting admins)
- Admin action log viewing
**To create the first super-admin**:
1. Register a user account
2. Access database: `sqlite3 src/backend/award.db`
3. Run: `UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';`
4. Log out and log back in to get updated JWT token
**To promote via admin interface**:
1. Log in as existing super-admin
2. Navigate to `/admin`
3. Find user in Users tab
4. Click "Promote" and select "Super Admin"

View File

@@ -258,6 +258,7 @@ The application will be available at:
### Awards ### Awards
- `GET /api/awards` - Get all available awards - `GET /api/awards` - Get all available awards
- `GET /api/awards/:awardId` - Get single award definition (includes mode groups)
- `GET /api/awards/batch/progress` - Get progress for all awards (optimized, single request) - `GET /api/awards/batch/progress` - Get progress for all awards (optimized, single request)
- `GET /api/awards/:awardId/progress` - Get award progress for a specific award - `GET /api/awards/:awardId/progress` - Get award progress for a specific award
- `GET /api/awards/:awardId/entities` - Get entity breakdown - `GET /api/awards/:awardId/entities` - Get entity breakdown
@@ -276,6 +277,52 @@ The application will be available at:
### Health ### Health
- `GET /api/health` - Health check endpoint - `GET /api/health` - Health check endpoint
### Admin API (Admin Only)
All admin endpoints require authentication and admin privileges.
- `GET /api/admin/stats` - Get system-wide statistics
- `GET /api/admin/users` - Get all users with statistics
- `GET /api/admin/users/:userId` - Get detailed information about a specific user
- `POST /api/admin/users/:userId/role` - Update user role (`user`, `admin`, `super-admin`)
- `DELETE /api/admin/users/:userId` - Delete a user
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
- `POST /api/admin/impersonate/stop` - Stop impersonating and return to admin account
- `GET /api/admin/impersonation/status` - Get current impersonation status
- `GET /api/admin/actions` - Get admin actions log
- `GET /api/admin/actions/my` - Get current admin's action log
### User Roles and Permissions
The application supports three user roles with different permission levels:
**Regular User**
- View own QSOs
- Sync from LoTW and DCL
- Track award progress
- Manage own credentials
**Admin**
- All user permissions
- View system statistics
- View all users
- Promote/demote regular users to/from admin
- Delete regular users
- Impersonate regular users (for support)
- View admin action log
**Super Admin**
- All admin permissions
- Promote/demote admins to/from super-admin
- Impersonate other admins (for support)
- Full access to all admin functions
**Security Rules:**
- Only super-admins can promote or demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin
## Database Schema ## Database Schema
### Users Table ### Users Table
@@ -288,6 +335,9 @@ CREATE TABLE users (
lotwUsername TEXT, lotwUsername TEXT,
lotwPassword TEXT, lotwPassword TEXT,
dclApiKey TEXT, -- DCL API key (for future use) dclApiKey TEXT, -- DCL API key (for future use)
isAdmin INTEGER DEFAULT 0 NOT NULL,
isSuperAdmin INTEGER DEFAULT 0 NOT NULL,
lastSeen INTEGER,
createdAt TEXT NOT NULL, createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL updatedAt TEXT NOT NULL
); );
@@ -800,6 +850,26 @@ bun run db:studio
## Features in Detail ## Features in Detail
### Mode Groups (Award Detail View)
The award detail view includes configurable mode groups for filtering multiple modes together:
**Available Mode Groups** (varies by award):
- **Mixed Mode**: All modes aggregated by band (default view)
- **Digi-Modes**: FT8, FT4, MFSK, PSK31, RTTY, JT65, JT9
- **Classic Digi-Modes**: PSK31, RTTY, JT65, JT9 (excludes FT8, FT4, MFSK)
- **Mixed-Mode w/o WSJT-Modes**: PSK31, RTTY, AM, SSB, FM, CW (excludes WSJT modes)
- **Phone-Modes**: AM, SSB, FM
- **Individual Modes**: CW, SSB, FT8, etc.
The mode filter dropdown displays:
1. Mixed Mode (default)
2. Mode groups (configurable per award)
3. Visual separator (`─────`)
4. Individual modes
Awards can define custom `modeGroups` in their JSON definition to add additional mode groupings.
### Background Job Queue ### Background Job Queue
The application uses an in-memory job queue system for async operations: The application uses an in-memory job queue system for async operations:

View File

@@ -0,0 +1,115 @@
{
"dxccBased": [
{ "entityId": 230, "country": "Germany", "prefix": "DL", "deleted": false },
{ "entityId": 227, "country": "France", "prefix": "F", "deleted": false },
{ "entityId": 248, "country": "Italy", "prefix": "I", "deleted": false },
{ "entityId": 223, "country": "England", "prefix": "G", "deleted": false },
{ "entityId": 279, "country": "Scotland", "prefix": "GM", "deleted": false },
{ "entityId": 265, "country": "Northern Ireland", "prefix": "GI", "deleted": false },
{ "entityId": 294, "country": "Wales", "prefix": "GW", "deleted": false },
{ "entityId": 114, "country": "Isle of Man", "prefix": "GD", "deleted": false },
{ "entityId": 122, "country": "Jersey", "prefix": "GJ", "deleted": false },
{ "entityId": 106, "country": "Guernsey", "prefix": "GU", "deleted": false },
{ "entityId": 236, "country": "Greece", "prefix": "SV", "deleted": false },
{ "entityId": 209, "country": "Belgium", "prefix": "ON", "deleted": false },
{ "entityId": 263, "country": "Netherlands", "prefix": "PA", "deleted": false },
{ "entityId": 287, "country": "Switzerland", "prefix": "HB", "deleted": false },
{ "entityId": 281, "country": "Spain", "prefix": "EA", "deleted": false },
{ "entityId": 272, "country": "Portugal", "prefix": "CT", "deleted": false },
{ "entityId": 206, "country": "Austria", "prefix": "OE", "deleted": false },
{ "entityId": 503, "country": "Czech Republic", "prefix": "OK", "deleted": false },
{ "entityId": 504, "country": "Slovakia", "prefix": "OM", "deleted": false },
{ "entityId": 239, "country": "Hungary", "prefix": "HA", "deleted": false },
{ "entityId": 269, "country": "Poland", "prefix": "SP", "deleted": false },
{ "entityId": 284, "country": "Sweden", "prefix": "SM", "deleted": false },
{ "entityId": 266, "country": "Norway", "prefix": "LA", "deleted": false },
{ "entityId": 221, "country": "Denmark", "prefix": "OZ", "deleted": false },
{ "entityId": 224, "country": "Finland", "prefix": "OH", "deleted": false },
{ "entityId": 52, "country": "Estonia", "prefix": "ES", "deleted": false },
{ "entityId": 145, "country": "Latvia", "prefix": "YL", "deleted": false },
{ "entityId": 146, "country": "Lithuania", "prefix": "LY", "deleted": false },
{ "entityId": 27, "country": "Belarus", "prefix": "EU", "deleted": false },
{ "entityId": 288, "country": "Ukraine", "prefix": "UR", "deleted": false },
{ "entityId": 179, "country": "Moldova", "prefix": "ER", "deleted": false },
{ "entityId": 275, "country": "Romania", "prefix": "YO", "deleted": false },
{ "entityId": 212, "country": "Bulgaria", "prefix": "LZ", "deleted": false },
{ "entityId": 296, "country": "Serbia", "prefix": "YT", "deleted": false },
{ "entityId": 497, "country": "Croatia", "prefix": "9A", "deleted": false },
{ "entityId": 499, "country": "Slovenia", "prefix": "S5", "deleted": false },
{ "entityId": 501, "country": "Bosnia and Herzegovina", "prefix": "E7", "deleted": false },
{ "entityId": 502, "country": "North Macedonia", "prefix": "Z3", "deleted": false },
{ "entityId": 7, "country": "Albania", "prefix": "ZA", "deleted": false },
{ "entityId": 514, "country": "Montenegro", "prefix": "4O", "deleted": false },
{ "entityId": 54, "country": "Russia (European)", "prefix": "UA", "deleted": false },
{ "entityId": 126, "country": "Kaliningrad", "prefix": "UA2", "deleted": false },
{ "entityId": 390, "country": "Turkey", "prefix": "TA", "deleted": false },
{ "entityId": 215, "country": "Cyprus", "prefix": "5B", "deleted": false },
{ "entityId": 257, "country": "Malta", "prefix": "9H", "deleted": false },
{ "entityId": 242, "country": "Iceland", "prefix": "TF", "deleted": false },
{ "entityId": 245, "country": "Ireland", "prefix": "EI", "deleted": false },
{ "entityId": 254, "country": "Luxembourg", "prefix": "LX", "deleted": false },
{ "entityId": 260, "country": "Monaco", "prefix": "3A", "deleted": false },
{ "entityId": 203, "country": "Andorra", "prefix": "C3", "deleted": false },
{ "entityId": 278, "country": "San Marino", "prefix": "T7", "deleted": false },
{ "entityId": 295, "country": "Vatican City", "prefix": "HV", "deleted": false },
{ "entityId": 251, "country": "Liechtenstein", "prefix": "HB0", "deleted": false }
],
"waeSpecific": [
{
"country": "Shetland Islands",
"prefix": "GM/S",
"callsigns": ["GM/S*", "GS/S*", "2M/S*"],
"parentDxcc": 279
},
{
"country": "European Turkey",
"prefix": "TA1",
"callsigns": ["TA1*"],
"parentDxcc": 390
},
{
"country": "Sardinia",
"prefix": "IS0",
"callsigns": ["IS0*"],
"parentDxcc": 248
},
{
"country": "Sicily",
"prefix": "IT9",
"callsigns": ["IT9*"],
"parentDxcc": 248
},
{
"country": "Corsica",
"prefix": "TK",
"callsigns": ["TK*"],
"parentDxcc": 227
},
{
"country": "Crete",
"prefix": "SV9",
"callsigns": ["SV9*", "J49*"],
"parentDxcc": 236
},
{
"country": "ITU Headquarters Geneva",
"prefix": "4U1I",
"callsigns": ["4U1I"],
"parentDxcc": null
},
{
"country": "UN Vienna",
"prefix": "4U1V",
"callsigns": ["4U1V"],
"parentDxcc": null
}
],
"deletedCountries": [
{
"country": "German Democratic Republic",
"prefix": "Y2",
"deleted": "1990-10-03",
"formerEntityId": 229
}
]
}

View File

@@ -8,6 +8,55 @@
"type": "dok", "type": "dok",
"target": 100, "target": 100,
"confirmationType": "dcl", "confirmationType": "dcl",
"displayField": "darcDok" "displayField": "darcDok",
} "stations": []
} },
"modeGroups": {
"Digi-Modes": [
"FT4",
"FT8",
"MFSK",
"PSK31",
"RTTY"
],
"Classic Digi-Modes": [
"PSK31",
"RTTY"
],
"Mixed-Mode w/o WSJT-Modes": [
"AM",
"CW",
"FM",
"PSK31",
"RTTY",
"SSB"
],
"Phone-Modes": [
"AM",
"FM",
"SSB"
]
},
"achievements": [
{
"name": "DLD50",
"threshold": 50
},
{
"name": "DLD100",
"threshold": 100
},
{
"name": "DLD200",
"threshold": 200
},
{
"name": "DLD500",
"threshold": 500
},
{
"name": "DLD1000",
"threshold": 1000
}
]
}

View File

@@ -9,6 +9,66 @@
"entityType": "dxcc", "entityType": "dxcc",
"target": 100, "target": 100,
"displayField": "entity", "displayField": "entity",
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"] "allowed_bands": [
} "160m",
} "80m",
"60m",
"40m",
"30m",
"20m",
"17m",
"15m",
"12m",
"10m"
],
"stations": []
},
"modeGroups": {
"Digi-Modes": [
"FT4",
"FT8",
"JT65",
"JT9",
"MFSK",
"PSK31",
"RTTY"
],
"Classic Digi-Modes": [
"JT65",
"JT9",
"PSK31",
"RTTY"
],
"Mixed-Mode w/o WSJT-Modes": [
"AM",
"CW",
"FM",
"PSK31",
"RTTY",
"SSB"
],
"Phone-Modes": [
"AM",
"FM",
"SSB"
]
},
"achievements": [
{
"name": "Silver",
"threshold": 100
},
{
"name": "Gold",
"threshold": 200
},
{
"name": "Platinum",
"threshold": 300
},
{
"name": "All",
"threshold": 341
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "qo100grids",
"name": "QO100 Grids",
"description": "Work as much Grids as possible on QO100 Satellite",
"caption": "Work as much Grids as possible on QO100 Satellite",
"category": "satellite",
"rules": {
"type": "entity",
"satellite_only": true,
"filters": {
"operator": "AND",
"filters": [
{
"field": "satName",
"operator": "eq",
"value": "QO-100"
}
]
},
"entityType": "grid",
"target": 100,
"displayField": "grid"
},
"modeGroups": {}
}

View File

@@ -1,9 +1,9 @@
{ {
"id": "sat-rs44", "id": "sat-rs44",
"name": "RS-44 Satellite", "name": "44 on RS-44",
"description": "Work 44 QSOs on satellite RS-44", "description": "Work 44 QSOs on satellite RS-44",
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.", "caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
"category": "custom", "category": "satellite",
"rules": { "rules": {
"type": "counter", "type": "counter",
"target": 44, "target": 44,
@@ -19,5 +19,6 @@
} }
] ]
} }
} },
} "modeGroups": {}
}

View File

@@ -9,14 +9,57 @@
"target": 50, "target": 50,
"countMode": "perBandMode", "countMode": "perBandMode",
"stations": [ "stations": [
{ "callsign": "DF2ET", "points": 10 }, {
{ "callsign": "DJ7NT", "points": 10 }, "callsign": "DF2ET",
{ "callsign": "HB9HIL", "points": 10 }, "points": 10
{ "callsign": "LA8AJA", "points": 10 }, },
{ "callsign": "DB4SCW", "points": 5 }, {
{ "callsign": "DG2RON", "points": 5 }, "callsign": "DJ7NT",
{ "callsign": "DG0TM", "points": 5 }, "points": 10
{ "callsign": "DO8MKR", "points": 5 } },
{
"callsign": "HB9HIL",
"points": 10
},
{
"callsign": "LA8AJA",
"points": 10
},
{
"callsign": "DB4SCW",
"points": 5
},
{
"callsign": "DG2RON",
"points": 5
},
{
"callsign": "DG0TM",
"points": 5
},
{
"callsign": "DO8MKR",
"points": 5
}
] ]
} },
} "modeGroups": {},
"achievements": [
{
"name": "Unicorn",
"threshold": 10
},
{
"name": "Few Devs",
"threshold": 20
},
{
"name": "More Devs",
"threshold": 40
},
{
"name": "Gold",
"threshold": 50
}
]
}

View File

@@ -0,0 +1,31 @@
{
"id": "vucc6m",
"name": "VUCC 6M",
"description": "Shows confirmed gridsquares on 6M",
"caption": "Shows confirmed gridsquares on 6M",
"category": "vucc",
"rules": {
"type": "entity",
"satellite_only": false,
"filters": {
"operator": "AND",
"filters": [
{
"field": "band",
"operator": "eq",
"value": "6m"
}
]
},
"entityType": "grid",
"countMode": "perStation",
"target": 100,
"allowed_bands": [
"6m"
],
"stations": [],
"displayField": "grid"
},
"modeGroups": {},
"achievements": []
}

View File

@@ -0,0 +1,33 @@
{
"id": "wae",
"name": "WAE",
"description": "Worked All Europe - Contact and confirm European countries from the WAE Country List",
"caption": "Worked All Europe Award. Bandpoints: 1 point per band (2 points for 80m/160m), maximum 5 bands per country. Available in multiple mode variants.",
"category": "darc",
"modeGroups": {
"CW": ["CW"],
"SSB": ["SSB", "AM", "FM"],
"RTTY": ["RTTY"],
"FT8": ["FT8"],
"Digi-Modes": ["FT4", "FT8", "JT65", "JT9", "MFSK", "PSK31", "RTTY"],
"Classic Digi-Modes": ["PSK31", "RTTY"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
},
"rules": {
"type": "wae",
"targetCountries": 40,
"targetBandpoints": 100,
"doublePointBands": ["160m", "80m"],
"maxBandsPerCountry": 5,
"excludeDeletedForTop": true,
"waeCountryList": "wae-country-list.json"
},
"achievements": [
{ "name": "WAE III", "thresholdCountries": 40, "thresholdBandpoints": 100 },
{ "name": "WAE II", "thresholdCountries": 50, "thresholdBandpoints": 150 },
{ "name": "WAE I", "thresholdCountries": 60, "thresholdBandpoints": 200 },
{ "name": "WAE TOP", "thresholdCountries": 70, "thresholdBandpoints": 300, "excludeDeleted": true },
{ "name": "WAE Trophy", "thresholdCountries": 999, "thresholdBandpoints": 365, "requireAllCountries": true }
]
}

View File

@@ -1,6 +1,6 @@
{ {
"id": "was-mixed", "id": "was-mixed",
"name": "WAS Mixed Mode", "name": "WAS",
"description": "Confirm all 50 US states", "description": "Confirm all 50 US states",
"caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.", "caption": "Contact and confirm all 50 US states. Only QSOs with stations located in United States states count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "was", "category": "was",
@@ -14,10 +14,22 @@
"filters": [ "filters": [
{ {
"field": "entityId", "field": "entityId",
"operator": "eq", "operator": "in",
"value": 291 "value": [
291,
6,
110
]
} }
] ]
},
"stations": []
},
"modeGroups": {},
"achievements": [
{
"name": "WAS Award",
"threshold": 50
} }
} ]
} }

View File

@@ -71,6 +71,7 @@ Every award definition is a JSON object with the following structure:
| `caption` | string | Yes | Detailed explanation of award requirements | | `caption` | string | Yes | Detailed explanation of award requirements |
| `category` | string | Yes | Grouping category for UI organization (e.g., "dxcc", "darc", "vucc") | | `category` | string | Yes | Grouping category for UI organization (e.g., "dxcc", "darc", "vucc") |
| `rules` | object | Yes | Award calculation rules (see Rule Types below) | | `rules` | object | Yes | Award calculation rules (see Rule Types below) |
| `modeGroups` | object | No | Mode group definitions for award detail view filtering (see Mode Groups below) |
--- ---
@@ -456,6 +457,85 @@ This creates: DLD 80m CW (only QSOs on 80m band AND CW mode count).
--- ---
## Mode Groups
Mode groups allow awards to define convenient multi-mode filters for the award detail view. This feature enables users to filter by multiple modes at once (e.g., all digital modes combined).
### Structure
```json
{
"modeGroups": {
"Group Display Name": ["mode1", "mode2", "mode3"],
"Another Group": ["modeA", "modeB"]
}
}
```
### Properties
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| Key | string | Yes | Display name shown in mode filter dropdown |
| Value | array | Yes | Array of mode strings to include in the group |
### Behavior
- **Location**: Defined at award root level (not in `rules`)
- **Optional**: Awards without `modeGroups` work as before (backward compatible)
- **UI Integration**: Creates entries in mode filter dropdown with visual separator
- **Filtering**: Selecting a mode group filter shows QSOs matching ANY mode in the group
### Example: DXCC with Mode Groups
```json
{
"id": "dxcc",
"name": "DXCC",
"description": "Confirm 100 DXCC entities on HF bands",
"caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m).",
"category": "dxcc",
"modeGroups": {
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
"Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
"Phone-Modes": ["AM", "SSB", "FM"]
},
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
}
}
```
### UI Display
In the award detail view, the mode filter dropdown shows:
- Mixed Mode (default - aggregates all modes by band)
- ───── (visual separator)
- Digi-Modes
- Classic Digi-Modes
- Mixed-Mode w/o WSJT-Modes
- Phone-Modes
- ───── (visual separator)
- CW (individual modes)
- FT8
- SSB
- etc.
### Common Mode Group Patterns
| Group Name | Modes | Use Case |
|------------|-------|----------|
| Digi-Modes | FT8, FT4, MFSK, PSK31, RTTY, JT65, JT9 | All digital modes |
| Classic Digi-Modes | PSK31, RTTY, JT65, JT9 | Pre-WSJT digital modes |
| Phone-Modes | AM, SSB, FM | All voice modes |
| Mixed-Mode w/o WSJT | PSK31, RTTY, AM, SSB, FM, CW | Traditional mixed mode award |
---
## QSO Database Schema ## QSO Database Schema
The award system relies on QSO records with specific fields. The award system relies on QSO records with specific fields.

View File

@@ -276,6 +276,9 @@ award/
callsign: text (not null) callsign: text (not null)
lotwUsername: text (nullable) lotwUsername: text (nullable)
lotwPassword: text (nullable, encrypted) lotwPassword: text (nullable, encrypted)
isAdmin: boolean (default: false)
isSuperAdmin: boolean (default: false)
lastSeen: timestamp (nullable)
createdAt: timestamp createdAt: timestamp
updatedAt: timestamp updatedAt: timestamp
} }
@@ -1034,7 +1037,197 @@ When adding new awards or modifying the award system:
--- ---
## Resources ## Admin System
### Overview
The admin system provides user management, role-based access control, and account impersonation capabilities for support and administrative purposes.
### User Roles
The application supports three user roles with increasing permissions:
#### Regular User
- View own QSOs and statistics
- Sync from LoTW and DCL
- Track award progress
- Manage own credentials (LoTW, DCL)
#### Admin
- All user permissions
- View system-wide statistics
- View all users and their activity
- Promote/demote regular users to/from admin role
- Delete regular users
- Impersonate regular users (for support)
- View admin action log
#### Super Admin
- All admin permissions
- Promote/demote admins to/from super-admin role
- Impersonate other admins (for support)
- Cannot be demoted by regular admins
- Protected from accidental lockout
### Security Rules
**Role Change Restrictions:**
- Only super-admins can promote or demote super-admins
- Regular admins cannot promote users to super-admin
- Super-admins cannot demote themselves
- Cannot demote the last super-admin (prevents lockout)
**Impersonation Restrictions:**
- Regular admins can only impersonate regular users
- Super-admins can impersonate any user (including other admins)
- All impersonation actions are logged to audit trail
- Impersonation tokens expire after 1 hour
### Admin API Endpoints
**Statistics and Monitoring:**
- `GET /api/admin/stats` - System-wide statistics (users, QSOs, jobs)
- `GET /api/admin/users` - List all users with statistics
- `GET /api/admin/users/:userId` - Get detailed user information
- `GET /api/admin/actions` - View admin action log
- `GET /api/admin/actions/my` - View current admin's actions
**User Management:**
- `POST /api/admin/users/:userId/role` - Change user role
- Body: `{ "role": "user" | "admin" | "super-admin" }`
- `DELETE /api/admin/users/:userId` - Delete a user
**Impersonation:**
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
- `POST /api/admin/impersonate/stop` - Stop impersonation
- `GET /api/admin/impersonation/status` - Check impersonation status
### Admin Service
**File:** `src/backend/services/admin.service.js`
**Key Functions:**
```javascript
// Check user permissions
await isAdmin(userId)
await isSuperAdmin(userId)
// Role management
await changeUserRole(adminId, targetUserId, newRole)
// Impersonation
await impersonateUser(adminId, targetUserId)
await verifyImpersonation(impersonationToken)
await stopImpersonation(adminId, targetUserId)
// Audit logging
await logAdminAction(adminId, actionType, targetUserId, details)
```
### Audit Logging
All admin actions are logged to the `admin_actions` table for audit purposes:
**Action Types:**
- `impersonate_start` - Started impersonating a user
- `impersonate_stop` - Stopped impersonation
- `role_change` - Changed user role
- `user_delete` - Deleted a user
**Log Entry Structure:**
```javascript
{
id: integer,
adminId: integer,
actionType: string,
targetUserId: integer (nullable),
details: string (JSON),
createdAt: timestamp
}
```
### Frontend Admin Interface
**Route:** `/admin` (admin only)
**Features:**
- **Overview Tab:** System statistics dashboard
- **Users Tab:** User management with filtering
- **Awards Tab:** Award definition management
- **Action Log Tab:** Audit trail of admin actions
**User Management Actions:**
- **Impersonate** - Switch to user account (disabled for admins unless super-admin)
- **Promote/Demote** - Change user role
- **Delete** - Remove user and all associated data
### JWT Token Claims
Admin tokens include additional claims:
```javascript
{
userId: number,
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean, // New: Super-admin flag
exp: number
}
```
**Impersonation Token:**
```javascript
{
userId: number, // Target user ID
email: string,
callsign: string,
isAdmin: boolean,
isSuperAdmin: boolean,
impersonatedBy: number, // Admin ID who started impersonation
exp: number // 1 hour expiration
}
```
### Setup
**To create the first super-admin:**
1. Register a user account normally
2. Access the database directly:
```bash
sqlite3 src/backend/award.db
```
3. Update the user to super-admin:
```sql
UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';
```
4. Log out and log back in to get the updated JWT token
**To promote users via the admin interface:**
1. Log in as a super-admin
2. Navigate to `/admin`
3. Find the user in the Users tab
4. Click "Promote" and select "Super Admin"
### Production Deployment
After pulling the latest code:
```bash
# Apply database migration (adds is_super_admin column)
sqlite3 src/backend/award.db "ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0 NOT NULL;"
# Restart backend
pm2 restart award-backend
# Promote a user to super-admin via database or existing admin interface
```
---
- [ARRL LoTW](https://lotw.arrl.org/) - [ARRL LoTW](https://lotw.arrl.org/)
- [DARC Community Logbook (DCL)](https://dcl.darc.de/) - [DARC Community Logbook (DCL)](https://dcl.darc.de/)

View File

@@ -0,0 +1,17 @@
CREATE TABLE `auto_sync_settings` (
`user_id` integer PRIMARY KEY NOT NULL,
`lotw_enabled` integer DEFAULT false NOT NULL,
`lotw_interval_hours` integer DEFAULT 24 NOT NULL,
`lotw_last_sync_at` integer,
`lotw_next_sync_at` integer,
`dcl_enabled` integer DEFAULT false NOT NULL,
`dcl_interval_hours` integer DEFAULT 24 NOT NULL,
`dcl_last_sync_at` integer,
`dcl_next_sync_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `users` ADD `is_super_admin` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `last_seen` integer;

View File

@@ -0,0 +1,868 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0d928d09-61c6-4311-beb8-0f597172e510",
"prevId": "071c98fb-6721-4da7-98cb-c16cb6aaf0c1",
"tables": {
"admin_actions": {
"name": "admin_actions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"admin_id": {
"name": "admin_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action_type": {
"name": "action_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_user_id": {
"name": "target_user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"details": {
"name": "details",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"admin_actions_admin_id_users_id_fk": {
"name": "admin_actions_admin_id_users_id_fk",
"tableFrom": "admin_actions",
"tableTo": "users",
"columnsFrom": [
"admin_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"admin_actions_target_user_id_users_id_fk": {
"name": "admin_actions_target_user_id_users_id_fk",
"tableFrom": "admin_actions",
"tableTo": "users",
"columnsFrom": [
"target_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"auto_sync_settings": {
"name": "auto_sync_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"lotw_enabled": {
"name": "lotw_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"lotw_interval_hours": {
"name": "lotw_interval_hours",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 24
},
"lotw_last_sync_at": {
"name": "lotw_last_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_next_sync_at": {
"name": "lotw_next_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_enabled": {
"name": "dcl_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dcl_interval_hours": {
"name": "dcl_interval_hours",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 24
},
"dcl_last_sync_at": {
"name": "dcl_last_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_next_sync_at": {
"name": "dcl_next_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"auto_sync_settings_user_id_users_id_fk": {
"name": "auto_sync_settings_user_id_users_id_fk",
"tableFrom": "auto_sync_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"award_progress": {
"name": "award_progress",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"award_id": {
"name": "award_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worked_count": {
"name": "worked_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"confirmed_count": {
"name": "confirmed_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_required": {
"name": "total_required",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"worked_entities": {
"name": "worked_entities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"confirmed_entities": {
"name": "confirmed_entities",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_calculated_at": {
"name": "last_calculated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_qso_sync_at": {
"name": "last_qso_sync_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"award_progress_user_id_users_id_fk": {
"name": "award_progress_user_id_users_id_fk",
"tableFrom": "award_progress",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"award_progress_award_id_awards_id_fk": {
"name": "award_progress_award_id_awards_id_fk",
"tableFrom": "award_progress",
"tableTo": "awards",
"columnsFrom": [
"award_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"awards": {
"name": "awards",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"qso_changes": {
"name": "qso_changes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"job_id": {
"name": "job_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"qso_id": {
"name": "qso_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"change_type": {
"name": "change_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"before_data": {
"name": "before_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"after_data": {
"name": "after_data",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"qso_changes_job_id_sync_jobs_id_fk": {
"name": "qso_changes_job_id_sync_jobs_id_fk",
"tableFrom": "qso_changes",
"tableTo": "sync_jobs",
"columnsFrom": [
"job_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"qso_changes_qso_id_qsos_id_fk": {
"name": "qso_changes_qso_id_qsos_id_fk",
"tableFrom": "qso_changes",
"tableTo": "qsos",
"columnsFrom": [
"qso_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"qsos": {
"name": "qsos",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"qso_date": {
"name": "qso_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"time_on": {
"name": "time_on",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"band": {
"name": "band",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mode": {
"name": "mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"freq": {
"name": "freq",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"freq_rx": {
"name": "freq_rx",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity": {
"name": "entity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grid": {
"name": "grid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grid_source": {
"name": "grid_source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"continent": {
"name": "continent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cq_zone": {
"name": "cq_zone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"itu_zone": {
"name": "itu_zone",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"county": {
"name": "county",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sat_name": {
"name": "sat_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sat_mode": {
"name": "sat_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"my_darc_dok": {
"name": "my_darc_dok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"darc_dok": {
"name": "darc_dok",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_qsl_rdate": {
"name": "lotw_qsl_rdate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_qsl_rstatus": {
"name": "lotw_qsl_rstatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_qsl_rdate": {
"name": "dcl_qsl_rdate",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_qsl_rstatus": {
"name": "dcl_qsl_rstatus",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_synced_at": {
"name": "lotw_synced_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"qsos_user_id_users_id_fk": {
"name": "qsos_user_id_users_id_fk",
"tableFrom": "qsos",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_jobs": {
"name": "sync_jobs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sync_jobs_user_id_users_id_fk": {
"name": "sync_jobs_user_id_users_id_fk",
"tableFrom": "sync_jobs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lotw_username": {
"name": "lotw_username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lotw_password": {
"name": "lotw_password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dcl_api_key": {
"name": "dcl_api_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_super_admin": {
"name": "is_super_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"last_seen": {
"name": "last_seen",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1768989260562, "when": 1768989260562,
"tag": "0003_tired_warpath", "tag": "0003_tired_warpath",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1769171258085,
"tag": "0004_overrated_havok",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,6 +10,8 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string|null} lotwPassword * @property {string|null} lotwPassword
* @property {string|null} dclApiKey * @property {string|null} dclApiKey
* @property {boolean} isAdmin * @property {boolean} isAdmin
* @property {boolean} isSuperAdmin
* @property {Date|null} lastSeen
* @property {Date} createdAt * @property {Date} createdAt
* @property {Date} updatedAt * @property {Date} updatedAt
*/ */
@@ -23,6 +25,8 @@ export const users = sqliteTable('users', {
lotwPassword: text('lotw_password'), // Encrypted lotwPassword: text('lotw_password'), // Encrypted
dclApiKey: text('dcl_api_key'), // DCL API key for future use dclApiKey: text('dcl_api_key'), // DCL API key for future use
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false), isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
isSuperAdmin: integer('is_super_admin', { mode: 'boolean' }).notNull().default(false),
lastSeen: integer('last_seen', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });
@@ -223,5 +227,39 @@ export const adminActions = sqliteTable('admin_actions', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });
/**
* @typedef {Object} AutoSyncSettings
* @property {number} userId
* @property {boolean} lotwEnabled
* @property {number} lotwIntervalHours
* @property {Date|null} lotwLastSyncAt
* @property {Date|null} lotwNextSyncAt
* @property {boolean} dclEnabled
* @property {number} dclIntervalHours
* @property {Date|null} dclLastSyncAt
* @property {Date|null} dclNextSyncAt
* @property {Date} createdAt
* @property {Date} updatedAt
*/
export const autoSyncSettings = sqliteTable('auto_sync_settings', {
userId: integer('user_id').primaryKey().references(() => users.id),
// LoTW auto-sync settings
lotwEnabled: integer('lotw_enabled', { mode: 'boolean' }).notNull().default(false),
lotwIntervalHours: integer('lotw_interval_hours').notNull().default(24),
lotwLastSyncAt: integer('lotw_last_sync_at', { mode: 'timestamp' }),
lotwNextSyncAt: integer('lotw_next_sync_at', { mode: 'timestamp' }),
// DCL auto-sync settings
dclEnabled: integer('dcl_enabled', { mode: 'boolean' }).notNull().default(false),
dclIntervalHours: integer('dcl_interval_hours').notNull().default(24),
dclLastSyncAt: integer('dcl_last_sync_at', { mode: 'timestamp' }),
dclNextSyncAt: integer('dcl_next_sync_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
// Export all schemas // Export all schemas
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions }; export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };

View File

@@ -12,6 +12,7 @@ import {
getUserById, getUserById,
updateLoTWCredentials, updateLoTWCredentials,
updateDCLCredentials, updateDCLCredentials,
updateLastSeen,
} from './services/auth.service.js'; } from './services/auth.service.js';
import { import {
getSystemStats, getSystemStats,
@@ -40,9 +41,28 @@ import {
} from './services/job-queue.service.js'; } from './services/job-queue.service.js';
import { import {
getAllAwards, getAllAwards,
getAwardById,
getAwardProgressDetails, getAwardProgressDetails,
getAwardEntityBreakdown, getAwardEntityBreakdown,
} from './services/awards.service.js'; } from './services/awards.service.js';
import {
getAllAwardDefinitions,
getAwardDefinition,
createAwardDefinition,
updateAwardDefinition,
deleteAwardDefinition,
testAwardCalculation,
} from './services/awards-admin.service.js';
import {
getAutoSyncSettings,
updateAutoSyncSettings,
} from './services/auto-sync.service.js';
import {
startScheduler,
stopScheduler,
getSchedulerStatus,
triggerSchedulerTick,
} from './services/scheduler.service.js';
/** /**
* Main backend application * Main backend application
@@ -188,6 +208,14 @@ const app = new Elysia()
return { user: null }; return { user: null };
} }
// Update last_seen timestamp asynchronously (don't await)
updateLastSeen(payload.userId).catch((err) => {
// Silently fail - last_seen update failure shouldn't block requests
if (LOG_LEVEL === 'debug') {
logger.warn('Failed to update last_seen', { error: err.message });
}
});
// Check if this is an impersonation token // Check if this is an impersonation token
const isImpersonation = !!payload.impersonatedBy; const isImpersonation = !!payload.impersonatedBy;
@@ -197,6 +225,7 @@ const app = new Elysia()
email: payload.email, email: payload.email,
callsign: payload.callsign, callsign: payload.callsign,
isAdmin: payload.isAdmin, isAdmin: payload.isAdmin,
isSuperAdmin: payload.isSuperAdmin,
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
}, },
isImpersonation, isImpersonation,
@@ -332,6 +361,8 @@ const app = new Elysia()
userId: user.id, userId: user.id,
email: user.email, email: user.email,
callsign: user.callsign, callsign: user.callsign,
isAdmin: user.isAdmin,
isSuperAdmin: user.isSuperAdmin,
exp, exp,
}); });
@@ -401,6 +432,7 @@ const app = new Elysia()
email: user.email, email: user.email,
callsign: user.callsign, callsign: user.callsign,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isSuperAdmin: user.isSuperAdmin,
exp, exp,
}); });
@@ -903,6 +935,42 @@ const app = new Elysia()
} }
}) })
/**
* GET /api/awards/:awardId
* Get a single award by ID (requires authentication)
*/
.get('/api/awards/:awardId', async ({ user, params, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const { awardId } = params;
const award = getAwardById(awardId);
if (!award) {
set.status = 404;
return {
success: false,
error: 'Award not found',
};
}
return {
success: true,
award,
};
} catch (error) {
logger.error('Error fetching award', { error: error.message });
set.status = 500;
return {
success: false,
error: 'Failed to fetch award',
};
}
})
/** /**
* GET /api/awards/:awardId/progress * GET /api/awards/:awardId/progress
* Get award progress for user (requires authentication) * Get award progress for user (requires authentication)
@@ -1145,7 +1213,7 @@ const app = new Elysia()
/** /**
* POST /api/admin/users/:userId/role * POST /api/admin/users/:userId/role
* Update user admin status (admin only) * Update user role (admin only)
*/ */
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => { .post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
if (!user || !user.isAdmin) { if (!user || !user.isAdmin) {
@@ -1159,21 +1227,27 @@ const app = new Elysia()
return { success: false, error: 'Invalid user ID' }; return { success: false, error: 'Invalid user ID' };
} }
const { isAdmin } = body; const { role } = body;
if (typeof isAdmin !== 'boolean') { if (typeof role !== 'string') {
set.status = 400; set.status = 400;
return { success: false, error: 'isAdmin (boolean) is required' }; return { success: false, error: 'role (string) is required' };
}
const validRoles = ['user', 'admin', 'super-admin'];
if (!validRoles.includes(role)) {
set.status = 400;
return { success: false, error: `Invalid role. Must be one of: ${validRoles.join(', ')}` };
} }
try { try {
await changeUserRole(user.id, targetUserId, isAdmin); await changeUserRole(user.id, targetUserId, role);
return { return {
success: true, success: true,
message: 'User admin status updated successfully', message: 'User role updated successfully',
}; };
} catch (error) { } catch (error) {
logger.error('Error updating user admin status', { error: error.message, userId: user.id }); logger.error('Error updating user role', { error: error.message, userId: user.id });
set.status = 400; set.status = 400;
return { return {
success: false, success: false,
@@ -1240,6 +1314,7 @@ const app = new Elysia()
email: targetUser.email, email: targetUser.email,
callsign: targetUser.callsign, callsign: targetUser.callsign,
isAdmin: targetUser.isAdmin, isAdmin: targetUser.isAdmin,
isSuperAdmin: targetUser.isSuperAdmin,
impersonatedBy: user.id, // Admin ID who started impersonation impersonatedBy: user.id, // Admin ID who started impersonation
exp, exp,
}); });
@@ -1300,6 +1375,7 @@ const app = new Elysia()
email: adminUser.email, email: adminUser.email,
callsign: adminUser.callsign, callsign: adminUser.callsign,
isAdmin: adminUser.isAdmin, isAdmin: adminUser.isAdmin,
isSuperAdmin: adminUser.isSuperAdmin,
exp, exp,
}); });
@@ -1398,6 +1474,348 @@ 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()),
achievements: 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
* ================================================================
* All auto-sync routes require authentication
*/
/**
* GET /api/auto-sync/settings
* Get user's auto-sync settings (requires authentication)
*/
.get('/api/auto-sync/settings', async ({ user, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const settings = await getAutoSyncSettings(user.id);
return {
success: true,
settings,
};
} catch (error) {
logger.error('Error fetching auto-sync settings', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch auto-sync settings',
};
}
})
/**
* PUT /api/auto-sync/settings
* Update user's auto-sync settings (requires authentication)
*/
.put(
'/api/auto-sync/settings',
async ({ user, body, set }) => {
if (!user) {
set.status = 401;
return { success: false, error: 'Unauthorized' };
}
try {
const settings = await updateAutoSyncSettings(user.id, body);
return {
success: true,
settings,
message: 'Auto-sync settings updated successfully',
};
} catch (error) {
logger.error('Error updating auto-sync settings', { error: error.message, userId: user.id });
set.status = 400;
return {
success: false,
error: error.message,
};
}
},
{
body: t.Object({
lotwEnabled: t.Optional(t.Boolean()),
lotwIntervalHours: t.Optional(t.Number()),
dclEnabled: t.Optional(t.Boolean()),
dclIntervalHours: t.Optional(t.Number()),
}),
}
)
/**
* GET /api/auto-sync/scheduler/status
* Get scheduler status (admin only)
*/
.get('/api/auto-sync/scheduler/status', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
const status = getSchedulerStatus();
return {
success: true,
scheduler: status,
};
} catch (error) {
logger.error('Error fetching scheduler status', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to fetch scheduler status',
};
}
})
/**
* POST /api/auto-sync/scheduler/trigger
* Manually trigger scheduler tick (admin only, for testing)
*/
.post('/api/auto-sync/scheduler/trigger', async ({ user, set }) => {
if (!user || !user.isAdmin) {
set.status = !user ? 401 : 403;
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
}
try {
await triggerSchedulerTick();
return {
success: true,
message: 'Scheduler tick triggered successfully',
};
} catch (error) {
logger.error('Error triggering scheduler tick', { error: error.message, userId: user.id });
set.status = 500;
return {
success: false,
error: 'Failed to trigger scheduler tick',
};
}
})
// Serve static files and SPA fallback for all non-API routes // Serve static files and SPA fallback for all non-API routes
.get('/*', ({ request }) => { .get('/*', ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -1554,3 +1972,21 @@ logger.info('Server started', {
nodeEnv: process.env.NODE_ENV || 'unknown', nodeEnv: process.env.NODE_ENV || 'unknown',
logLevel: LOG_LEVEL, logLevel: LOG_LEVEL,
}); });
// Start the auto-sync scheduler
startScheduler();
// Graceful shutdown handlers
const gracefulShutdown = async (signal) => {
logger.info(`Received ${signal}, shutting down gracefully...`);
// Stop the scheduler
await stopScheduler();
logger.info('Graceful shutdown complete');
process.exit(0);
};
// Handle shutdown signals
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));

View File

@@ -0,0 +1,111 @@
/**
* Migration: Add auto_sync_settings table
*
* This script creates the auto_sync_settings table for managing
* automatic sync intervals for DCL and LoTW services.
* Users can enable/disable auto-sync and configure sync intervals.
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add auto-sync settings...');
try {
// Check if auto_sync_settings table already exists
const tableExists = sqlite.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='auto_sync_settings'
`).get();
if (tableExists) {
console.log('Table auto_sync_settings already exists. Skipping...');
} else {
// Create auto_sync_settings table
sqlite.exec(`
CREATE TABLE auto_sync_settings (
user_id INTEGER PRIMARY KEY,
lotw_enabled INTEGER NOT NULL DEFAULT 0,
lotw_interval_hours INTEGER NOT NULL DEFAULT 24,
lotw_last_sync_at INTEGER,
lotw_next_sync_at INTEGER,
dcl_enabled INTEGER NOT NULL DEFAULT 0,
dcl_interval_hours INTEGER NOT NULL DEFAULT 24,
dcl_last_sync_at INTEGER,
dcl_next_sync_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create index for faster queries on next_sync_at
sqlite.exec(`
CREATE INDEX idx_auto_sync_settings_lotw_next_sync_at
ON auto_sync_settings(lotw_next_sync_at)
WHERE lotw_enabled = 1
`);
sqlite.exec(`
CREATE INDEX idx_auto_sync_settings_dcl_next_sync_at
ON auto_sync_settings(dcl_next_sync_at)
WHERE dcl_enabled = 1
`);
console.log('Created auto_sync_settings table with indexes');
}
console.log('Migration complete! Auto-sync settings table added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
async function rollback() {
console.log('Starting rollback: Remove auto-sync settings...');
try {
// Drop indexes first
sqlite.exec(`DROP INDEX IF EXISTS idx_auto_sync_settings_lotw_next_sync_at`);
sqlite.exec(`DROP INDEX IF EXISTS idx_auto_sync_settings_dcl_next_sync_at`);
// Drop table
sqlite.exec(`DROP TABLE IF EXISTS auto_sync_settings`);
console.log('Rollback complete! Auto-sync settings table removed from database.');
} catch (error) {
console.error('Rollback failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Check if this is a rollback
const args = process.argv.slice(2);
if (args.includes('--rollback') || args.includes('-r')) {
rollback().then(() => {
console.log('Rollback script completed successfully');
process.exit(0);
});
} else {
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});
}

View File

@@ -0,0 +1,86 @@
/**
* Migration: Add last_seen column to users table
*
* This script adds the last_seen column to track when users last accessed the tool.
*/
import Database from 'bun:sqlite';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, '../award.db');
const sqlite = new Database(dbPath);
async function migrate() {
console.log('Starting migration: Add last_seen column...');
try {
// Check if last_seen column already exists
const columnExists = sqlite.query(`
SELECT COUNT(*) as count
FROM pragma_table_info('users')
WHERE name='last_seen'
`).get();
if (columnExists.count > 0) {
console.log('Column last_seen already exists. Skipping...');
} else {
// Add last_seen column
sqlite.exec(`
ALTER TABLE users
ADD COLUMN last_seen INTEGER
`);
console.log('Added last_seen column to users table');
}
console.log('Migration complete! last_seen column added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
async function rollback() {
console.log('Starting rollback: Remove last_seen column...');
try {
// SQLite doesn't support DROP COLUMN directly before version 3.35.5
// For older versions, we need to recreate the table
console.log('Note: SQLite does not support DROP COLUMN. Manual cleanup required.');
console.log('To rollback: Recreate users table without last_seen column');
// For SQLite 3.35.5+, you can use:
// sqlite.exec(`ALTER TABLE users DROP COLUMN last_seen`);
console.log('Rollback note issued.');
} catch (error) {
console.error('Rollback failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Check if this is a rollback
const args = process.argv.slice(2);
if (args.includes('--rollback') || args.includes('-r')) {
rollback().then(() => {
console.log('Rollback script completed');
process.exit(0);
});
} else {
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});
}

View File

@@ -1,7 +1,7 @@
import { eq, sql, desc } from 'drizzle-orm'; import { eq, sql, desc } from 'drizzle-orm';
import { db, sqlite, logger } from '../config.js'; import { db, sqlite, logger } from '../config.js';
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js'; import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
import { getUserByIdFull, isAdmin } from './auth.service.js'; import { getUserByIdFull, isAdmin, isSuperAdmin, updateUserRole } from './auth.service.js';
/** /**
* Log an admin action for audit trail * Log an admin action for audit trail
@@ -127,6 +127,7 @@ export async function getUserStats() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`, qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`, dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
@@ -144,10 +145,13 @@ export async function getUserStats() {
.groupBy(users.id) .groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`); .orderBy(sql`COUNT(${qsos.id}) DESC`);
// Convert lastSync timestamps (seconds) to Date objects for JSON serialization // Convert timestamps (seconds) to Date objects for JSON serialization
// Note: lastSeen from Drizzle is already a Date object (timestamp mode)
// lastSync is raw SQL returning seconds, needs conversion
return stats.map(stat => ({ return stats.map(stat => ({
...stat, ...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null, lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
// lastSeen is already a Date object from Drizzle, don't convert
})); }));
} }
@@ -156,7 +160,7 @@ export async function getUserStats() {
* @param {number} adminId - Admin user ID * @param {number} adminId - Admin user ID
* @param {number} targetUserId - Target user ID to impersonate * @param {number} targetUserId - Target user ID to impersonate
* @returns {Promise<Object>} Target user object * @returns {Promise<Object>} Target user object
* @throws {Error} If not admin or trying to impersonate another admin * @throws {Error} If not admin or trying to impersonate another admin (without super-admin)
*/ */
export async function impersonateUser(adminId, targetUserId) { export async function impersonateUser(adminId, targetUserId) {
// Verify the requester is an admin // Verify the requester is an admin
@@ -171,9 +175,17 @@ export async function impersonateUser(adminId, targetUserId) {
throw new Error('Target user not found'); throw new Error('Target user not found');
} }
// Check if target is also an admin (prevent admin impersonation) // Check if target is also an admin
if (targetUser.isAdmin) { if (targetUser.isAdmin) {
throw new Error('Cannot impersonate another admin user'); // Only super-admins can impersonate other admins
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
if (!requesterIsSuperAdmin) {
throw new Error('Cannot impersonate another admin user (super-admin required)');
}
// Prevent self-impersonation (edge case)
if (adminId === targetUserId) {
throw new Error('Cannot impersonate yourself');
}
} }
// Log impersonation action // Log impersonation action
@@ -267,48 +279,69 @@ export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
* Update user admin status (admin operation) * Update user admin status (admin operation)
* @param {number} adminId - Admin user ID making the change * @param {number} adminId - Admin user ID making the change
* @param {number} targetUserId - User ID to update * @param {number} targetUserId - User ID to update
* @param {boolean} newIsAdmin - New admin flag * @param {string} newRole - New role: 'user', 'admin', or 'super-admin'
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If not admin or would remove last admin * @throws {Error} If not admin or violates security rules
*/ */
export async function changeUserRole(adminId, targetUserId, newIsAdmin) { export async function changeUserRole(adminId, targetUserId, newRole) {
// Validate role
const validRoles = ['user', 'admin', 'super-admin'];
if (!validRoles.includes(newRole)) {
throw new Error('Invalid role. Must be one of: user, admin, super-admin');
}
// Verify the requester is an admin // Verify the requester is an admin
const requesterIsAdmin = await isAdmin(adminId); const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) { if (!requesterIsAdmin) {
throw new Error('Only admins can change user admin status'); throw new Error('Only admins can change user roles');
} }
// Get requester super-admin status
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
// Get target user // Get target user
const targetUser = await getUserByIdFull(targetUserId); const targetUser = await getUserByIdFull(targetUserId);
if (!targetUser) { if (!targetUser) {
throw new Error('Target user not found'); throw new Error('Target user not found');
} }
// If demoting from admin, check if this would remove the last admin // Security rules for super-admin role changes
if (targetUser.isAdmin && !newIsAdmin) { const targetWillBeSuperAdmin = newRole === 'super-admin';
const adminCount = await db const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin;
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
.from(users)
.where(eq(users.isAdmin, 1));
if (adminCount[0].count === 1) { // Only super-admins can promote/demote super-admins
throw new Error('Cannot demote the last admin user'); if (targetWillBeSuperAdmin || targetIsCurrentlySuperAdmin) {
if (!requesterIsSuperAdmin) {
throw new Error('Only super-admins can promote or demote super-admins');
} }
} }
// Update admin status // Prevent self-demotion (super-admins cannot demote themselves)
await db if (adminId === targetUserId) {
.update(users) if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
.set({ throw new Error('Cannot demote yourself from super-admin');
isAdmin: newIsAdmin ? 1 : 0, }
updatedAt: new Date(), }
})
.where(eq(users.id, targetUserId)); // Cannot demote the last super-admin
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
const superAdminCount = await db
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
.from(users)
.where(eq(users.isSuperAdmin, 1));
if (superAdminCount[0].count === 1) {
throw new Error('Cannot demote the last super-admin');
}
}
// Update role (use the auth service function)
await updateUserRole(targetUserId, newRole);
// Log action // Log action
await logAdminAction(adminId, 'role_change', targetUserId, { await logAdminAction(adminId, 'role_change', targetUserId, {
oldIsAdmin: targetUser.isAdmin, oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'),
newIsAdmin: newIsAdmin, newRole: newRole,
}); });
} }

View File

@@ -158,6 +158,21 @@ export async function isAdmin(userId) {
return user?.isAdmin === true || user?.isAdmin === 1; return user?.isAdmin === true || user?.isAdmin === 1;
} }
/**
* Check if user is super-admin
* @param {number} userId - User ID
* @returns {Promise<boolean>} True if user is super-admin
*/
export async function isSuperAdmin(userId) {
const [user] = await db
.select({ isSuperAdmin: users.isSuperAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return user?.isSuperAdmin === true || user?.isSuperAdmin === 1;
}
/** /**
* Get all admin users * Get all admin users
* @returns {Promise<Array>} Array of admin users (without passwords) * @returns {Promise<Array>} Array of admin users (without passwords)
@@ -178,16 +193,20 @@ export async function getAdminUsers() {
} }
/** /**
* Update user admin status * Update user role
* @param {number} userId - User ID * @param {number} userId - User ID
* @param {boolean} isAdmin - Admin flag * @param {string} role - Role: 'user', 'admin', or 'super-admin'
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function updateUserRole(userId, isAdmin) { export async function updateUserRole(userId, role) {
const isAdmin = role === 'admin' || role === 'super-admin';
const isSuperAdmin = role === 'super-admin';
await db await db
.update(users) .update(users)
.set({ .set({
isAdmin: isAdmin ? 1 : 0, isAdmin: isAdmin ? 1 : 0,
isSuperAdmin: isSuperAdmin ? 1 : 0,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
@@ -204,6 +223,8 @@ export async function getAllUsers() {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
isSuperAdmin: users.isSuperAdmin,
lastSeen: users.lastSeen,
createdAt: users.createdAt, createdAt: users.createdAt,
updatedAt: users.updatedAt, updatedAt: users.updatedAt,
}) })
@@ -225,6 +246,7 @@ export async function getUserByIdFull(userId) {
email: users.email, email: users.email,
callsign: users.callsign, callsign: users.callsign,
isAdmin: users.isAdmin, isAdmin: users.isAdmin,
isSuperAdmin: users.isSuperAdmin,
lotwUsername: users.lotwUsername, lotwUsername: users.lotwUsername,
dclApiKey: users.dclApiKey, dclApiKey: users.dclApiKey,
createdAt: users.createdAt, createdAt: users.createdAt,
@@ -236,3 +258,17 @@ export async function getUserByIdFull(userId) {
return user || null; return user || null;
} }
/**
* Update user's last seen timestamp
* @param {number} userId - User ID
* @returns {Promise<void>}
*/
export async function updateLastSeen(userId) {
await db
.update(users)
.set({
lastSeen: new Date(),
})
.where(eq(users.id, userId));
}

View File

@@ -0,0 +1,373 @@
import { db, logger } from '../config.js';
import { autoSyncSettings, users } from '../db/schema/index.js';
import { eq, and, lte, or } from 'drizzle-orm';
/**
* Auto-Sync Settings Service
* Manages user preferences for automatic DCL and LoTW synchronization
*/
// Validation constants
export const MIN_INTERVAL_HOURS = 1;
export const MAX_INTERVAL_HOURS = 720; // 30 days
export const DEFAULT_INTERVAL_HOURS = 24;
/**
* Get auto-sync settings for a user
* Creates default settings if they don't exist
*
* @param {number} userId - User ID
* @returns {Promise<Object>} Auto-sync settings
*/
export async function getAutoSyncSettings(userId) {
try {
let [settings] = await db
.select()
.from(autoSyncSettings)
.where(eq(autoSyncSettings.userId, userId));
// Create default settings if they don't exist
if (!settings) {
logger.debug('Creating default auto-sync settings for user', { userId });
[settings] = await db
.insert(autoSyncSettings)
.values({
userId,
lotwEnabled: false,
lotwIntervalHours: DEFAULT_INTERVAL_HOURS,
dclEnabled: false,
dclIntervalHours: DEFAULT_INTERVAL_HOURS,
})
.returning();
}
return {
lotwEnabled: settings.lotwEnabled,
lotwIntervalHours: settings.lotwIntervalHours,
lotwLastSyncAt: settings.lotwLastSyncAt,
lotwNextSyncAt: settings.lotwNextSyncAt,
dclEnabled: settings.dclEnabled,
dclIntervalHours: settings.dclIntervalHours,
dclLastSyncAt: settings.dclLastSyncAt,
dclNextSyncAt: settings.dclNextSyncAt,
};
} catch (error) {
logger.error('Failed to get auto-sync settings', { error: error.message, userId });
throw error;
}
}
/**
* Validate interval hours
* @param {number} hours - Interval hours to validate
* @returns {Object} Validation result
*/
function validateIntervalHours(hours) {
if (typeof hours !== 'number' || isNaN(hours)) {
return { valid: false, error: 'Interval must be a number' };
}
if (!Number.isInteger(hours)) {
return { valid: false, error: 'Interval must be a whole number of hours' };
}
if (hours < MIN_INTERVAL_HOURS) {
return { valid: false, error: `Interval must be at least ${MIN_INTERVAL_HOURS} hour` };
}
if (hours > MAX_INTERVAL_HOURS) {
return { valid: false, error: `Interval must be at most ${MAX_INTERVAL_HOURS} hours (30 days)` };
}
return { valid: true };
}
/**
* Calculate next sync time based on interval
* @param {number} intervalHours - Interval in hours
* @returns {Date} Next sync time
*/
function calculateNextSyncTime(intervalHours) {
const nextSync = new Date();
nextSync.setHours(nextSync.getHours() + intervalHours);
return nextSync;
}
/**
* Update auto-sync settings for a user
*
* @param {number} userId - User ID
* @param {Object} settings - Settings to update
* @returns {Promise<Object>} Updated settings
*/
export async function updateAutoSyncSettings(userId, settings) {
try {
// Get current settings
let [currentSettings] = await db
.select()
.from(autoSyncSettings)
.where(eq(autoSyncSettings.userId, userId));
// Create default settings if they don't exist
if (!currentSettings) {
[currentSettings] = await db
.insert(autoSyncSettings)
.values({
userId,
lotwEnabled: false,
lotwIntervalHours: DEFAULT_INTERVAL_HOURS,
dclEnabled: false,
dclIntervalHours: DEFAULT_INTERVAL_HOURS,
})
.returning();
}
// Prepare update data
const updateData = {
updatedAt: new Date(),
};
// Validate and update LoTW settings
if (settings.lotwEnabled !== undefined) {
if (typeof settings.lotwEnabled !== 'boolean') {
throw new Error('lotwEnabled must be a boolean');
}
updateData.lotwEnabled = settings.lotwEnabled;
// If enabling for the first time or interval changed, set next sync time
if (settings.lotwEnabled && (!currentSettings.lotwEnabled || settings.lotwIntervalHours)) {
const intervalHours = settings.lotwIntervalHours || currentSettings.lotwIntervalHours;
const validation = validateIntervalHours(intervalHours);
if (!validation.valid) {
throw new Error(`LoTW interval: ${validation.error}`);
}
updateData.lotwNextSyncAt = calculateNextSyncTime(intervalHours);
} else if (!settings.lotwEnabled) {
// Clear next sync when disabling
updateData.lotwNextSyncAt = null;
}
}
if (settings.lotwIntervalHours !== undefined) {
const validation = validateIntervalHours(settings.lotwIntervalHours);
if (!validation.valid) {
throw new Error(`LoTW interval: ${validation.error}`);
}
updateData.lotwIntervalHours = settings.lotwIntervalHours;
// Update next sync time if LoTW is enabled
if (currentSettings.lotwEnabled || settings.lotwEnabled) {
updateData.lotwNextSyncAt = calculateNextSyncTime(settings.lotwIntervalHours);
}
}
// Validate and update DCL settings
if (settings.dclEnabled !== undefined) {
if (typeof settings.dclEnabled !== 'boolean') {
throw new Error('dclEnabled must be a boolean');
}
updateData.dclEnabled = settings.dclEnabled;
// If enabling for the first time or interval changed, set next sync time
if (settings.dclEnabled && (!currentSettings.dclEnabled || settings.dclIntervalHours)) {
const intervalHours = settings.dclIntervalHours || currentSettings.dclIntervalHours;
const validation = validateIntervalHours(intervalHours);
if (!validation.valid) {
throw new Error(`DCL interval: ${validation.error}`);
}
updateData.dclNextSyncAt = calculateNextSyncTime(intervalHours);
} else if (!settings.dclEnabled) {
// Clear next sync when disabling
updateData.dclNextSyncAt = null;
}
}
if (settings.dclIntervalHours !== undefined) {
const validation = validateIntervalHours(settings.dclIntervalHours);
if (!validation.valid) {
throw new Error(`DCL interval: ${validation.error}`);
}
updateData.dclIntervalHours = settings.dclIntervalHours;
// Update next sync time if DCL is enabled
if (currentSettings.dclEnabled || settings.dclEnabled) {
updateData.dclNextSyncAt = calculateNextSyncTime(settings.dclIntervalHours);
}
}
// Update settings in database
const [updated] = await db
.update(autoSyncSettings)
.set(updateData)
.where(eq(autoSyncSettings.userId, userId))
.returning();
logger.info('Updated auto-sync settings', {
userId,
lotwEnabled: updated.lotwEnabled,
lotwIntervalHours: updated.lotwIntervalHours,
dclEnabled: updated.dclEnabled,
dclIntervalHours: updated.dclIntervalHours,
});
return {
lotwEnabled: updated.lotwEnabled,
lotwIntervalHours: updated.lotwIntervalHours,
lotwLastSyncAt: updated.lotwLastSyncAt,
lotwNextSyncAt: updated.lotwNextSyncAt,
dclEnabled: updated.dclEnabled,
dclIntervalHours: updated.dclIntervalHours,
dclLastSyncAt: updated.dclLastSyncAt,
dclNextSyncAt: updated.dclNextSyncAt,
};
} catch (error) {
logger.error('Failed to update auto-sync settings', { error: error.message, userId });
throw error;
}
}
/**
* Get users with pending syncs for a specific service
*
* @param {string} service - 'lotw' or 'dcl'
* @returns {Promise<Array>} List of users with pending syncs
*/
export async function getPendingSyncUsers(service) {
try {
if (service !== 'lotw' && service !== 'dcl') {
throw new Error('Service must be "lotw" or "dcl"');
}
const enabledField = service === 'lotw' ? autoSyncSettings.lotwEnabled : autoSyncSettings.dclEnabled;
const nextSyncField = service === 'lotw' ? autoSyncSettings.lotwNextSyncAt : autoSyncSettings.dclNextSyncAt;
const credentialField = service === 'lotw' ? users.lotwUsername : users.dclApiKey;
const intervalField = service === 'lotw' ? autoSyncSettings.lotwIntervalHours : autoSyncSettings.dclIntervalHours;
const now = new Date();
// Get users with auto-sync enabled and next sync time in the past
const results = await db
.select({
userId: autoSyncSettings.userId,
lotwEnabled: autoSyncSettings.lotwEnabled,
lotwIntervalHours: autoSyncSettings.lotwIntervalHours,
lotwNextSyncAt: autoSyncSettings.lotwNextSyncAt,
dclEnabled: autoSyncSettings.dclEnabled,
dclIntervalHours: autoSyncSettings.dclIntervalHours,
dclNextSyncAt: autoSyncSettings.dclNextSyncAt,
hasCredentials: credentialField, // Just check if field exists (not null/empty)
})
.from(autoSyncSettings)
.innerJoin(users, eq(autoSyncSettings.userId, users.id))
.where(
and(
eq(enabledField, true),
lte(nextSyncField, now)
)
);
// Split into users with and without credentials
const withCredentials = results.filter(r => r.hasCredentials);
const withoutCredentials = results.filter(r => !r.hasCredentials);
// For users without credentials, update their next sync time to retry in 24 hours
// This prevents them from being continuously retried every minute
if (withoutCredentials.length > 0) {
const retryDate = new Date();
retryDate.setHours(retryDate.getHours() + 24);
for (const user of withoutCredentials) {
const updateData = {
updatedAt: new Date(),
};
if (service === 'lotw') {
updateData.lotwNextSyncAt = retryDate;
} else {
updateData.dclNextSyncAt = retryDate;
}
await db
.update(autoSyncSettings)
.set(updateData)
.where(eq(autoSyncSettings.userId, user.userId));
}
logger.warn('Skipped auto-sync for users without credentials, will retry in 24 hours', {
service,
count: withoutCredentials.length,
userIds: withoutCredentials.map(u => u.userId),
});
}
logger.debug('Found pending sync users', {
service,
total: results.length,
withCredentials: withCredentials.length,
withoutCredentials: withoutCredentials.length,
});
return withCredentials;
} catch (error) {
logger.error('Failed to get pending sync users', { error: error.message, service });
return [];
}
}
/**
* Update sync timestamps after a successful sync
*
* @param {number} userId - User ID
* @param {string} service - 'lotw' or 'dcl'
* @param {Date} lastSyncDate - Date of last sync
* @returns {Promise<void>}
*/
export async function updateSyncTimestamps(userId, service, lastSyncDate) {
try {
if (service !== 'lotw' && service !== 'dcl') {
throw new Error('Service must be "lotw" or "dcl"');
}
// Get current settings to find the interval
const [currentSettings] = await db
.select()
.from(autoSyncSettings)
.where(eq(autoSyncSettings.userId, userId));
if (!currentSettings) {
logger.warn('No auto-sync settings found for user', { userId, service });
return;
}
const intervalHours = service === 'lotw'
? currentSettings.lotwIntervalHours
: currentSettings.dclIntervalHours;
// Calculate next sync time
const nextSyncAt = calculateNextSyncTime(intervalHours);
// Update timestamps
const updateData = {
updatedAt: new Date(),
};
if (service === 'lotw') {
updateData.lotwLastSyncAt = lastSyncDate;
updateData.lotwNextSyncAt = nextSyncAt;
} else {
updateData.dclLastSyncAt = lastSyncDate;
updateData.dclNextSyncAt = nextSyncAt;
}
await db
.update(autoSyncSettings)
.set(updateData)
.where(eq(autoSyncSettings.userId, userId));
logger.debug('Updated sync timestamps', {
userId,
service,
lastSyncAt: lastSyncDate.toISOString(),
nextSyncAt: nextSyncAt.toISOString(),
});
} catch (error) {
logger.error('Failed to update sync timestamps', { error: error.message, userId, service });
throw error;
}
}

View File

@@ -0,0 +1,570 @@
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 achievements if present
if (definition.achievements) {
if (!Array.isArray(definition.achievements)) {
errors.push('achievements must be an array');
} else {
for (let i = 0; i < definition.achievements.length; i++) {
const achievement = definition.achievements[i];
if (!achievement.name || typeof achievement.name !== 'string') {
errors.push(`Achievement ${i + 1} must have a name`);
}
if (typeof achievement.threshold !== 'number' || achievement.threshold <= 0) {
errors.push(`Achievement "${achievement.name || i + 1}" must have a positive threshold`);
}
}
// Check for duplicate thresholds
const thresholds = definition.achievements.map(a => a.threshold);
const uniqueThresholds = new Set(thresholds);
if (thresholds.length !== uniqueThresholds.size) {
errors.push('Achievements must have unique thresholds');
}
}
}
// 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

@@ -30,8 +30,7 @@ function loadAwardDefinitions() {
try { try {
// Auto-discover all JSON files in the award-definitions directory // Auto-discover all JSON files in the award-definitions directory
const files = readdirSync(AWARD_DEFINITIONS_DIR) const files = readdirSync(AWARD_DEFINITIONS_DIR)
.filter(f => f.endsWith('.json')) .filter(f => f.endsWith('.json'));
.sort();
for (const file of files) { for (const file of files) {
try { try {
@@ -47,12 +46,103 @@ function loadAwardDefinitions() {
logger.error('Error loading award definitions', { error: error.message }); logger.error('Error loading award definitions', { error: error.message });
} }
// Sort by award name with numeric prefixes in numerical order
definitions.sort((a, b) => {
const nameA = a.name || '';
const nameB = b.name || '';
// Extract leading numbers if present
const matchA = nameA.match(/^(\d+)/);
const matchB = nameB.match(/^(\d+)/);
// If both start with numbers, compare numerically first
if (matchA && matchB) {
const numA = parseInt(matchA[1], 10);
const numB = parseInt(matchB[1], 10);
if (numA !== numB) {
return numA - numB;
}
// If numbers are equal, fall through to alphabetical
}
// If one starts with a number, it comes first
else if (matchA) return -1;
else if (matchB) return 1;
// Otherwise, alphabetical comparison (case-insensitive)
return nameA.toLowerCase().localeCompare(nameB.toLowerCase());
});
// Cache the definitions for future calls // Cache the definitions for future calls
cachedAwardDefinitions = definitions; cachedAwardDefinitions = definitions;
return definitions; return definitions;
} }
/**
* Clear the cached award definitions
* Call this after creating, updating, or deleting award definitions
*/
export function clearAwardCache() {
cachedAwardDefinitions = null;
cachedWAECountryList = null;
logger.info('Award cache cleared');
}
/**
* Calculate achievement progress for an award
* @param {number} currentCount - Current confirmed count (entities or points)
* @param {Array} achievements - Array of achievement definitions
* @returns {Object|null} Achievement progress info or null if no achievements defined
*/
function calculateAchievementProgress(currentCount, achievements) {
if (!achievements || achievements.length === 0) {
return null;
}
// Sort achievements by threshold
const sorted = [...achievements].sort((a, b) => a.threshold - b.threshold);
// Find earned achievements, current level, and next level
const earned = [];
let currentLevel = null;
let nextLevel = null;
for (let i = 0; i < sorted.length; i++) {
const achievement = sorted[i];
if (currentCount >= achievement.threshold) {
earned.push(achievement);
currentLevel = achievement;
} else {
nextLevel = achievement;
break;
}
}
// Calculate progress toward next level
let progressPercent = 100;
let progressCurrent = currentCount;
let progressNeeded = 0;
if (nextLevel) {
const prevThreshold = currentLevel ? currentLevel.threshold : 0;
const range = nextLevel.threshold - prevThreshold;
const progressInLevel = currentCount - prevThreshold;
progressPercent = Math.round((progressInLevel / range) * 100);
progressNeeded = nextLevel.threshold - currentCount;
}
return {
earned,
currentLevel,
nextLevel,
progressPercent,
progressCurrent,
progressNeeded,
totalAchievements: sorted.length,
earnedCount: earned.length,
};
}
/** /**
* Get all available awards * Get all available awards
*/ */
@@ -66,9 +156,36 @@ export async function getAllAwards() {
caption: def.caption, caption: def.caption,
category: def.category, category: def.category,
rules: def.rules, rules: def.rules,
modeGroups: def.modeGroups || null,
achievements: def.achievements || null,
})); }));
} }
/**
* Get a single award by ID
* @param {string} awardId - Award ID
* @returns {Object|null} Award definition or null if not found
*/
export function getAwardById(awardId) {
const definitions = loadAwardDefinitions();
const award = definitions.find((def) => def.id === awardId);
if (!award) {
return null;
}
return {
id: award.id,
name: award.name,
description: award.description,
caption: award.caption,
category: award.category,
rules: award.rules,
modeGroups: award.modeGroups || null,
achievements: award.achievements || null,
};
}
/** /**
* Calculate award progress for a user * Calculate award progress for a user
* @param {number} userId - User ID * @param {number} userId - User ID
@@ -126,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
return calculatePointsAwardProgress(userId, award, { includeDetails }); return calculatePointsAwardProgress(userId, award, { includeDetails });
} }
// Handle WAE-based awards (Worked All Europe)
if (rules.type === 'wae') {
return calculateWAEAwardProgress(userId, award, { includeDetails });
}
// Get all QSOs for user // Get all QSOs for user
const allQSOs = await db const allQSOs = await db
.select() .select()
@@ -175,7 +297,7 @@ export async function calculateAwardProgress(userId, award, options = {}) {
} }
} }
return { const result = {
worked: workedEntities.size, worked: workedEntities.size,
confirmed: confirmedEntities.size, confirmed: confirmedEntities.size,
target: rules.target || 0, target: rules.target || 0,
@@ -183,6 +305,13 @@ export async function calculateAwardProgress(userId, award, options = {}) {
workedEntities: Array.from(workedEntities), workedEntities: Array.from(workedEntities),
confirmedEntities: Array.from(confirmedEntities), confirmedEntities: Array.from(confirmedEntities),
}; };
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateAchievementProgress(confirmedEntities.size, award.achievements);
}
return result;
} }
/** /**
@@ -303,6 +432,11 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
result.confirmed = result.entities.filter((e) => e.confirmed).length; result.confirmed = result.entities.filter((e) => e.confirmed).length;
} }
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateAchievementProgress(confirmedDOKs.size, award.achievements);
}
return result; return result;
} }
@@ -564,6 +698,12 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
result.stationDetails = stationDetails; result.stationDetails = stationDetails;
} }
// Add achievement progress if award has achievements defined
// For point-based awards, use totalPoints instead of confirmed count
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateAchievementProgress(totalPoints, award.achievements);
}
return result; return result;
} }
@@ -634,6 +774,503 @@ function matchesFilter(qso, filter) {
} }
} }
// ============================================================================
// WAE (Worked All Europe) Award Functions
// ============================================================================
// In-memory cache for WAE country list
let cachedWAECountryList = null;
/**
* Load WAE country list from JSON file
*/
function loadWAECountryList() {
if (cachedWAECountryList) {
return cachedWAECountryList;
}
try {
const filePath = join(process.cwd(), 'award-data', 'wae-country-list.json');
const content = readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
// Build lookup maps for efficient matching
const dxccMap = new Map();
const waeSpecificMap = new Map();
const deletedPrefixes = new Set();
// Index DXCC-based countries
if (data.dxccBased) {
for (const entry of data.dxccBased) {
dxccMap.set(entry.entityId, {
country: entry.country,
prefix: entry.prefix,
deleted: entry.deleted || false,
});
}
}
// Index WAE-specific countries with callsign patterns
if (data.waeSpecific) {
for (const entry of data.waeSpecific) {
waeSpecificMap.set(entry.prefix, {
country: entry.country,
prefix: entry.prefix,
callsigns: entry.callsigns || [],
parentDxcc: entry.parentDxcc,
});
}
}
// Index deleted countries
if (data.deletedCountries) {
for (const entry of data.deletedCountries) {
deletedPrefixes.add(entry.prefix);
}
}
cachedWAECountryList = {
dxccMap,
waeSpecificMap,
deletedPrefixes,
rawData: data,
};
logger.debug('WAE country list loaded', {
dxccCount: dxccMap.size,
waeSpecificCount: waeSpecificMap.size,
deletedCount: deletedPrefixes.size,
});
return cachedWAECountryList;
} catch (error) {
logger.error('Failed to load WAE country list', { error: error.message });
return { dxccMap: new Map(), waeSpecificMap: new Map(), deletedPrefixes: new Set(), rawData: null };
}
}
/**
* Match a callsign to WAE country
* Only matches if the country is explicitly in the WAE country list
* @param {string} callsign - The callsign to match
* @param {number} entityId - The DXCC entityId from QSO
* @returns {Object|null} WAE country info or null if not a WAE country
*/
function matchWAECountry(callsign, entityId) {
const waeList = loadWAECountryList();
if (!callsign) return null;
const normalizedCallsign = callsign.toUpperCase().trim();
// First check WAE-specific patterns (these override DXCC)
for (const [prefix, info] of waeList.waeSpecificMap) {
for (const pattern of info.callsigns) {
if (pattern.includes('*')) {
// Wildcard pattern matching
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
if (regex.test(normalizedCallsign)) {
return {
country: info.country,
prefix: info.prefix,
isDeleted: false,
isWAESpecific: true,
};
}
} else {
// Exact match
if (normalizedCallsign === pattern) {
return {
country: info.country,
prefix: info.prefix,
isDeleted: false,
isWAESpecific: true,
};
}
}
}
}
// Only match DXCC entities that are EXPLICITLY in the WAE country list
// Do NOT fall back to matching any DXCC entity - WAE has its own list
if (entityId && waeList.dxccMap.has(entityId)) {
const dxccInfo = waeList.dxccMap.get(entityId);
return {
country: dxccInfo.country,
prefix: dxccInfo.prefix,
isDeleted: dxccInfo.deleted,
isWAESpecific: false,
};
}
// Not a WAE country (includes all non-European entities like US, JA, etc.)
return null;
}
/**
* Get bandpoint value for a band
* @param {string} band - The band name
* @param {Array} doublePointBands - Bands that count double
* @returns {number} Point value for this band
*/
function getBandpointValue(band, doublePointBands = []) {
if (doublePointBands && doublePointBands.includes(band)) {
return 2;
}
return 1;
}
/**
* Sort bands by point value (descending) for max bands per country calculation
* @param {Array} bands - Array of band names
* @param {Array} doublePointBands - Bands that count double
* @returns {Array} Bands sorted by point value
*/
function sortBandsByPointValue(bands, doublePointBands = []) {
return bands.sort((a, b) => {
const pointsA = getBandpointValue(a, doublePointBands);
const pointsB = getBandpointValue(b, doublePointBands);
if (pointsA !== pointsB) {
return pointsB - pointsA; // Higher points first
}
// Tie-breaker: prefer lower frequency (longer wavelength) bands
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
const indexA = bandOrder.indexOf(a);
const indexB = bandOrder.indexOf(b);
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
return a.localeCompare(b);
});
}
/**
* Calculate progress for WAE awards
* WAE tracks dual metrics: unique countries AND bandpoints
* @param {number} userId - User ID
* @param {Object} award - Award definition
* @param {Object} options - Options
* @param {boolean} options.includeDetails - Include detailed entity breakdown
*/
async function calculateWAEAwardProgress(userId, award, options = {}) {
const { includeDetails = false } = options;
const { rules } = award;
const {
targetCountries = 40,
targetBandpoints = 100,
doublePointBands = ['160m', '80m'],
maxBandsPerCountry = 5,
excludeDeletedForTop = true,
} = rules;
logger.debug('Calculating WAE award progress', {
userId,
awardId: award.id,
targetCountries,
targetBandpoints,
doublePointBands,
maxBandsPerCountry,
excludeDeletedForTop,
});
// Get all QSOs for user
const allQSOs = await db
.select()
.from(qsos)
.where(eq(qsos.userId, userId));
logger.debug('Total QSOs for WAE calculation', { count: allQSOs.length });
// Track per-country data
// Map: country -> { confirmed: boolean, bands: Set, bandpoints: number, qsos: [] }
const countryData = new Map();
// Track all unique countries worked and confirmed
const workedCountries = new Set();
const confirmedCountries = new Set();
for (const qso of allQSOs) {
const waeCountry = matchWAECountry(qso.callsign, qso.entityId);
if (!waeCountry) {
// Not a WAE country, skip
continue;
}
const country = waeCountry.country;
// Track worked countries
workedCountries.add(country);
// Check for LoTW confirmation
if (qso.lotwQslRstatus === 'Y') {
confirmedCountries.add(country);
// Initialize country data if not exists
if (!countryData.has(country)) {
countryData.set(country, {
country,
prefix: waeCountry.prefix,
isDeleted: waeCountry.isDeleted,
isWAESpecific: waeCountry.isWAESpecific,
confirmed: true,
bands: new Set(),
bandpoints: 0,
qsos: [],
});
}
const data = countryData.get(country);
const band = qso.band || 'Unknown';
// Only count this band if we haven't seen it before for this country
if (!data.bands.has(band)) {
data.bands.add(band);
// Calculate bandpoints for this country
// Get all confirmed bands for this country, sort by point value, take top N
const allBands = Array.from(data.bands);
const sortedBands = sortBandsByPointValue(allBands, doublePointBands);
const bandsToCount = sortedBands.slice(0, maxBandsPerCountry);
// Recalculate total bandpoints
let newBandpoints = 0;
for (const b of bandsToCount) {
newBandpoints += getBandpointValue(b, doublePointBands);
}
data.bandpoints = newBandpoints;
}
// Add QSO to the qsos array for drill-down
data.qsos.push({
qsoId: qso.id,
callsign: qso.callsign,
mode: qso.mode,
qsoDate: qso.qsoDate,
timeOn: qso.timeOn,
band: qso.band,
satName: qso.satName,
confirmed: true,
});
}
}
// Calculate total bandpoints across all countries
let totalBandpoints = 0;
for (const data of countryData.values()) {
totalBandpoints += data.bandpoints;
}
// For WAE TOP/Trophy, we may need to exclude deleted countries
let displayConfirmedCount = confirmedCountries.size;
let displayWorkedCount = workedCountries.size;
let displayTotalBandpoints = totalBandpoints;
// Check if this is for WAE TOP or Trophy (which exclude deleted countries)
if (excludeDeletedForTop) {
let confirmedWithoutDeleted = 0;
for (const country of confirmedCountries) {
const data = countryData.get(country);
if (data && !data.isDeleted) {
confirmedWithoutDeleted++;
}
}
// Store both counts
displayConfirmedCount = confirmedWithoutDeleted;
}
logger.debug('WAE award progress calculated', {
workedCountries: displayWorkedCount,
confirmedCountries: displayConfirmedCount,
totalBandpoints: displayTotalBandpoints,
targetCountries,
targetBandpoints,
});
// Build result
const result = {
worked: displayWorkedCount,
confirmed: displayConfirmedCount,
bandpoints: displayTotalBandpoints,
workedBandpoints: displayTotalBandpoints, // For consistency with worked/confirmed naming
targetCountries,
targetBandpoints,
percentage: targetCountries ? Math.round((displayConfirmedCount / targetCountries) * 100) : 0,
bandpointsPercentage: targetBandpoints ? Math.min(100, Math.round((displayTotalBandpoints / targetBandpoints) * 100)) : 0,
workedEntities: Array.from(workedCountries),
confirmedEntities: Array.from(confirmedCountries),
};
// Add details if requested
if (includeDetails) {
result.award = {
id: award.id,
name: award.name,
description: award.description,
caption: award.caption,
targetCountries,
targetBandpoints,
};
// Build entities array for detail view
// For WAE, we need to expand countries into (country, band, mode) slots
// to match the frontend's expected (entity, band, mode) structure
const expandedEntities = [];
for (const [countryName, data] of countryData) {
const { bands, bandpoints, qsos, prefix, isDeleted, isWAESpecific } = data;
// For each band, create a slot entry
for (const band of bands) {
// Get all modes used on this band for this country
const modesInBand = new Set();
for (const qso of qsos) {
if (qso.band === band) {
modesInBand.add(qso.mode || 'Unknown');
}
}
// Create a slot for each mode on this band
for (const mode of modesInBand) {
// Get QSOs for this specific (country, band, mode) combination
const slotQSOs = qsos.filter(q => q.band === band && q.mode === mode);
expandedEntities.push({
entity: countryName,
entityId: null,
entityName: countryName,
prefix,
isDeleted,
isWAESpecific,
band,
mode,
confirmed: true,
bandpoints: getBandpointValue(band, award.rules.doublePointBands),
worked: true,
qsos: slotQSOs,
});
}
}
// If no bands (shouldn't happen for confirmed countries, but handle edge case)
if (bands.length === 0 && confirmedCountries.has(countryName)) {
expandedEntities.push({
entity: countryName,
entityId: null,
entityName: countryName,
prefix,
isDeleted,
isWAESpecific,
band: 'Unknown',
mode: 'Unknown',
confirmed: true,
bandpoints: 0,
worked: true,
qsos: [],
});
}
}
result.entities = expandedEntities;
result.total = expandedEntities.length;
result.confirmed = expandedEntities.filter((e) => e.confirmed).length;
}
// Add achievement progress if award has achievements defined
if (award.achievements && award.achievements.length > 0) {
result.achievements = calculateWAEAchievementProgress(
displayConfirmedCount,
displayTotalBandpoints,
award.achievements,
excludeDeletedForTop
);
}
return result;
}
/**
* Calculate achievement progress for WAE awards (dual thresholds)
* @param {number} confirmedCountries - Number of confirmed countries
* @param {number} totalBandpoints - Total bandpoints earned
* @param {Array} achievements - Array of achievement definitions
* @param {boolean} excludeDeletedForTop - Whether deleted countries are excluded
* @returns {Object} Achievement progress info
*/
function calculateWAEAchievementProgress(confirmedCountries, totalBandpoints, achievements, excludeDeletedForTop) {
if (!achievements || achievements.length === 0) {
return null;
}
// Sort achievements by thresholdCountries
const sorted = [...achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
// Find earned achievements, current level, and next level
const earned = [];
let currentLevel = null;
let nextLevel = null;
for (let i = 0; i < sorted.length; i++) {
const achievement = sorted[i];
// Check if achievement criteria are met
// For achievements with excludeDeleted flag, we need both thresholds met
// Otherwise, just check country and bandpoint thresholds
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
// Special handling for "requireAllCountries" (WAE Trophy)
let allCountriesMet = false;
if (achievement.requireAllCountries) {
const waeList = loadWAECountryList();
const totalCountries = waeList.dxccMap.size + waeList.waeSpecificMap.size;
allCountriesMet = confirmedCountries >= totalCountries;
}
const criteriaMet = achievement.requireAllCountries
? (allCountriesMet && bandpointsMet)
: (countriesMet && bandpointsMet);
if (criteriaMet) {
earned.push(achievement);
currentLevel = achievement;
} else {
nextLevel = achievement;
break;
}
}
// Calculate progress toward next level
let progressPercent = 100;
let progressCurrent = confirmedCountries;
let progressNeeded = 0;
let progressBandpointsCurrent = totalBandpoints;
let progressBandpointsNeeded = 0;
if (nextLevel) {
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
const range = nextLevel.thresholdCountries - prevThreshold;
const progressInLevel = confirmedCountries - prevThreshold;
progressPercent = Math.round((progressInLevel / range) * 100);
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
}
return {
earned,
currentLevel,
nextLevel,
progressPercent,
progressCurrent: confirmedCountries,
progressNeeded,
progressBandpointsCurrent: totalBandpoints,
progressBandpointsNeeded,
totalAchievements: sorted.length,
earnedCount: earned.length,
};
}
/** /**
* Get award progress with QSO details * Get award progress with QSO details
*/ */
@@ -717,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
return await calculatePointsAwardProgress(userId, award, { includeDetails: true }); return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
} }
// Handle WAE-based awards - use the dedicated function
if (rules.type === 'wae') {
return await calculateWAEAwardProgress(userId, award, { includeDetails: true });
}
// Get all QSOs for user // Get all QSOs for user
const allQSOs = await db const allQSOs = await db
.select() .select()
@@ -762,7 +1404,19 @@ export async function getAwardEntityBreakdown(userId, awardId) {
} }
displayName = String(rawValue || entity); displayName = String(rawValue || entity);
} else { } else {
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity); // Smart default based on entityType when displayField is not specified
const defaultDisplayField = {
'dxcc': 'entity',
'state': 'state',
'grid': 'grid',
'callsign': 'callsign'
}[rules.entityType] || 'entity';
let rawValue = qso[defaultDisplayField];
if (defaultDisplayField === 'grid' && rawValue && rawValue.length > 4) {
rawValue = rawValue.substring(0, 4);
}
displayName = String(rawValue || entity);
} }
if (!slotMap.has(slotKey)) { if (!slotMap.has(slotKey)) {

View File

@@ -552,7 +552,7 @@ export async function getQSOStats(userId) {
}).from(qsos).where(eq(qsos.userId, userId)), }).from(qsos).where(eq(qsos.userId, userId)),
db.select({ db.select({
uniqueEntities: sql`CAST(COUNT(DISTINCT entity) AS INTEGER)`, uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`, uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)` uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
}).from(qsos).where(eq(qsos.userId, userId)) }).from(qsos).where(eq(qsos.userId, userId))

View File

@@ -0,0 +1,234 @@
import { logger } from '../config.js';
import {
getPendingSyncUsers,
updateSyncTimestamps,
} from './auto-sync.service.js';
import {
enqueueJob,
getUserActiveJob,
} from './job-queue.service.js';
import { getUserById } from './auth.service.js';
/**
* Auto-Sync Scheduler Service
* Manages automatic synchronization of DCL and LoTW data
* Runs every minute to check for due syncs and enqueues jobs
*/
// Scheduler state
let schedulerInterval = null;
let isRunning = false;
let isShuttingDown = false;
// Scheduler configuration
const SCHEDULER_TICK_INTERVAL_MS = 60 * 1000; // 1 minute
const INITIAL_DELAY_MS = 5000; // 5 seconds after server start
// Allow faster tick interval for testing (set via environment variable)
const TEST_MODE = process.env.SCHEDULER_TEST_MODE === 'true';
const TEST_TICK_INTERVAL_MS = 10 * 1000; // 10 seconds in test mode
/**
* Get scheduler status
* @returns {Object} Scheduler status
*/
export function getSchedulerStatus() {
return {
isRunning,
isShuttingDown,
tickIntervalMs: TEST_MODE ? TEST_TICK_INTERVAL_MS : SCHEDULER_TICK_INTERVAL_MS,
activeInterval: !!schedulerInterval,
testMode: TEST_MODE,
};
}
/**
* Process pending syncs for a specific service
* @param {string} service - 'lotw' or 'dcl'
*/
async function processServiceSyncs(service) {
try {
const pendingUsers = await getPendingSyncUsers(service);
if (pendingUsers.length === 0) {
logger.debug('No pending syncs', { service });
return;
}
logger.info('Processing pending syncs', {
service,
count: pendingUsers.length,
});
for (const user of pendingUsers) {
if (isShuttingDown) {
logger.info('Scheduler shutting down, skipping pending sync', {
service,
userId: user.userId,
});
break;
}
try {
// Check if there's already an active job for this user and service
const activeJob = await getUserActiveJob(user.userId, `${service}_sync`);
if (activeJob) {
logger.debug('User already has active job, skipping', {
service,
userId: user.userId,
activeJobId: activeJob.id,
});
// Update the next sync time to try again later
// This prevents continuous checking while a job is running
await updateSyncTimestamps(user.userId, service, new Date());
continue;
}
// Enqueue the sync job
logger.info('Enqueuing auto-sync job', {
service,
userId: user.userId,
});
const result = await enqueueJob(user.userId, `${service}_sync`);
if (result.success) {
// Update timestamps immediately on successful enqueue
await updateSyncTimestamps(user.userId, service, new Date());
} else {
logger.warn('Failed to enqueue auto-sync job', {
service,
userId: user.userId,
reason: result.error,
});
}
} catch (error) {
logger.error('Error processing user sync', {
service,
userId: user.userId,
error: error.message,
});
}
}
} catch (error) {
logger.error('Error processing service syncs', {
service,
error: error.message,
});
}
}
/**
* Main scheduler tick function
* Checks for pending LoTW and DCL syncs and processes them
*/
async function schedulerTick() {
if (isShuttingDown) {
logger.debug('Scheduler shutdown in progress, skipping tick');
return;
}
try {
logger.debug('Scheduler tick started');
// Process LoTW syncs
await processServiceSyncs('lotw');
// Process DCL syncs
await processServiceSyncs('dcl');
logger.debug('Scheduler tick completed');
} catch (error) {
logger.error('Scheduler tick error', {
error: error.message,
stack: error.stack,
});
}
}
/**
* Start the scheduler
* Begins periodic checks for pending syncs
*/
export function startScheduler() {
if (isRunning) {
logger.warn('Scheduler already running');
return;
}
// Check if scheduler is disabled via environment variable
if (process.env.DISABLE_SCHEDULER === 'true') {
logger.info('Scheduler disabled via DISABLE_SCHEDULER environment variable');
return;
}
isRunning = true;
isShuttingDown = false;
const tickInterval = TEST_MODE ? TEST_TICK_INTERVAL_MS : SCHEDULER_TICK_INTERVAL_MS;
// Initial delay to allow server to fully start
logger.info('Scheduler starting, initial tick in 5 seconds', {
testMode: TEST_MODE,
tickIntervalMs: tickInterval,
});
// Schedule first tick
setTimeout(() => {
if (!isShuttingDown) {
schedulerTick();
// Set up recurring interval
schedulerInterval = setInterval(() => {
if (!isShuttingDown) {
schedulerTick();
}
}, tickInterval);
logger.info('Scheduler started', {
tickIntervalMs: tickInterval,
testMode: TEST_MODE,
});
}
}, INITIAL_DELAY_MS);
}
/**
* Stop the scheduler gracefully
* Waits for current tick to complete before stopping
*/
export async function stopScheduler() {
if (!isRunning) {
logger.debug('Scheduler not running');
return;
}
logger.info('Stopping scheduler...');
isShuttingDown = true;
// Clear the interval
if (schedulerInterval) {
clearInterval(schedulerInterval);
schedulerInterval = null;
}
// Wait a moment for any in-progress tick to complete
await new Promise(resolve => setTimeout(resolve, 100));
isRunning = false;
logger.info('Scheduler stopped');
}
/**
* Trigger an immediate scheduler tick (for testing or manual sync)
*/
export async function triggerSchedulerTick() {
if (!isRunning) {
throw new Error('Scheduler is not running');
}
logger.info('Manual scheduler tick triggered');
await schedulerTick();
}

186
src/frontend/src/app.css Normal file
View File

@@ -0,0 +1,186 @@
/* Quickawards Theme System - CSS Variables */
/* Light Mode (default) */
:root, [data-theme="light"] {
/* Backgrounds */
--bg-body: #f5f5f5;
--bg-card: #ffffff;
--bg-navbar: #2c3e50;
--bg-footer: #2c3e50;
--bg-input: #ffffff;
--bg-hover: rgba(255, 255, 255, 0.1);
--bg-secondary: #f8f9fa;
--bg-tertiary: #e9ecef;
/* Text */
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverted: #ffffff;
--text-link: #4a90e2;
/* Primary colors */
--color-primary: #4a90e2;
--color-primary-hover: #357abd;
--color-primary-light: rgba(74, 144, 226, 0.1);
/* Secondary colors */
--color-secondary: #6c757d;
--color-secondary-hover: #5a6268;
/* Semantic colors */
--color-success: #065f46;
--color-success-bg: #d1fae5;
--color-success-light: #10b981;
--color-warning: #ffc107;
--color-warning-hover: #e0a800;
--color-warning-bg: #fff3cd;
--color-warning-text: #856404;
--color-error: #dc3545;
--color-error-hover: #c82333;
--color-error-bg: #fee2e2;
--color-error-text: #991b1b;
--color-info: #1e40af;
--color-info-bg: #dbeafe;
--color-info-text: #1e40af;
/* Badge/status colors */
--badge-pending-bg: #fef3c7;
--badge-pending-text: #92400e;
--badge-running-bg: #dbeafe;
--badge-running-text: #1e40af;
--badge-completed-bg: #d1fae5;
--badge-completed-text: #065f46;
--badge-failed-bg: #fee2e2;
--badge-failed-text: #991b1b;
--badge-cancelled-bg: #f3e8ff;
--badge-cancelled-text: #6b21a8;
--badge-purple-bg: #8b5cf6;
--badge-purple-text: #ffffff;
/* Borders */
--border-color: #e0e0e0;
--border-color-light: #e9ecef;
--border-radius: 4px;
--border-radius-lg: 8px;
--border-radius-pill: 12px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.12);
/* Focus */
--focus-ring: 0 0 0 2px rgba(74, 144, 226, 0.2);
/* Logout button */
--color-logout: #ff6b6b;
--color-logout-hover: #ff5252;
--color-logout-bg: rgba(255, 107, 107, 0.1);
/* Admin link */
--color-admin-bg: #ffc107;
--color-admin-hover: #e0a800;
--color-admin-text: #000000;
/* Impersonation banner */
--impersonation-bg: #fff3cd;
--impersonation-border: #ffc107;
--impersonation-text: #856404;
/* Gradient colors */
--gradient-primary: linear-gradient(90deg, #4a90e2 0%, #357abd 100%);
--gradient-purple: linear-gradient(90deg, #8b5cf6, #a78bfa);
--gradient-scheduled: linear-gradient(to right, #f8f7ff, white);
}
/* Dark Mode */
[data-theme="dark"] {
/* Backgrounds */
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--bg-navbar: #1f2937;
--bg-footer: #1f2937;
--bg-input: #2d2d2d;
--bg-hover: rgba(255, 255, 255, 0.1);
--bg-secondary: #252525;
--bg-tertiary: #333333;
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--text-inverted: #ffffff;
--text-link: #5ba3f5;
/* Primary colors */
--color-primary: #5ba3f5;
--color-primary-hover: #4a8ae4;
--color-primary-light: rgba(91, 163, 245, 0.15);
/* Secondary colors */
--color-secondary: #6b7280;
--color-secondary-hover: #4b5563;
/* Semantic colors */
--color-success: #10b981;
--color-success-bg: #064e3b;
--color-success-light: #10b981;
--color-warning: #fbbf24;
--color-warning-hover: #f59e0b;
--color-warning-bg: #451a03;
--color-warning-text: #fef3c7;
--color-error: #f87171;
--color-error-hover: #ef4444;
--color-error-bg: #7f1d1d;
--color-error-text: #fecaca;
--color-info: #3b82f6;
--color-info-bg: #1e3a8a;
--color-info-text: #93c5fd;
/* Badge/status colors */
--badge-pending-bg: #451a03;
--badge-pending-text: #fef3c7;
--badge-running-bg: #1e3a8a;
--badge-running-text: #93c5fd;
--badge-completed-bg: #064e3b;
--badge-completed-text: #6ee7b7;
--badge-failed-bg: #7f1d1d;
--badge-failed-text: #fecaca;
--badge-cancelled-bg: #3b0a4d;
--badge-cancelled-text: #d8b4fe;
--badge-purple-bg: #7c3aed;
--badge-purple-text: #ffffff;
/* Borders */
--border-color: #404040;
--border-color-light: #4a4a4a;
/* Focus */
--focus-ring: 0 0 0 2px rgba(91, 163, 245, 0.2);
/* Logout button */
--color-logout: #f87171;
--color-logout-hover: #ef4444;
--color-logout-bg: rgba(248, 113, 113, 0.15);
/* Admin link */
--color-admin-bg: #f59e0b;
--color-admin-hover: #d97706;
--color-admin-text: #000000;
/* Impersonation banner */
--impersonation-bg: #451a03;
--impersonation-border: #f59e0b;
--impersonation-text: #fef3c7;
/* Gradient colors */
--gradient-primary: linear-gradient(90deg, #5ba3f5 0%, #4a8ae4 100%);
--gradient-purple: linear-gradient(90deg, #7c3aed, #8b5cf6);
--gradient-scheduled: linear-gradient(to right, #2d1f3d, #2d2d2d);
}

View File

@@ -4,6 +4,14 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<script>
// Prevent flash of unstyled content (FOUC)
(function() {
const theme = localStorage.getItem('theme') || 'light';
const isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
})();
</script>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>

View File

@@ -95,9 +95,9 @@ export const adminAPI = {
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`), getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, { updateUserRole: (userId, role) => apiRequest(`/admin/users/${userId}/role`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ isAdmin }), body: JSON.stringify({ role }),
}), }),
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, { deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
@@ -118,3 +118,41 @@ export const adminAPI = {
getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`), getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`),
}; };
// Auto-Sync API
export const autoSyncAPI = {
getSettings: () => apiRequest('/auto-sync/settings'),
updateSettings: (settings) => apiRequest('/auto-sync/settings', {
method: 'PUT',
body: JSON.stringify(settings),
}),
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
};
// Awards Admin API
export const awardsAdminAPI = {
getAll: () => apiRequest('/admin/awards'),
getById: (id) => apiRequest(`/admin/awards/${id}`),
create: (data) => apiRequest('/admin/awards', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id, data) => apiRequest(`/admin/awards/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id) => apiRequest(`/admin/awards/${id}`, {
method: 'DELETE',
}),
test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, {
method: 'POST',
body: JSON.stringify({ userId, awardDefinition }),
}),
};

View File

@@ -11,10 +11,10 @@
<style> <style>
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -22,12 +22,12 @@
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.back-button.secondary { .back-button.secondary {
background-color: transparent; background-color: transparent;
color: #4a90e2; color: var(--color-primary);
padding: 0; padding: 0;
} }

View File

@@ -16,21 +16,21 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #d32f2f; color: var(--color-error);
} }
.btn { .btn {
display: inline-block; display: inline-block;
margin-top: 1rem; margin-top: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
} }
.btn:hover { .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
</style> </style>

View File

@@ -11,6 +11,6 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--text-secondary);
} }
</style> </style>

View File

@@ -0,0 +1,152 @@
<script>
import { theme } from '$lib/stores/theme.js';
import { browser } from '$app/environment';
let isOpen = false;
const themes = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '💻' },
];
function selectTheme(value) {
theme.setTheme(value);
isOpen = false;
}
function toggle() {
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (!event.target.closest('.theme-switcher')) {
isOpen = false;
}
}
if (browser) {
document.addEventListener('click', handleClickOutside);
}
</script>
<svelte:head>
<style>
/* Ensure dropdown is above other content */
.theme-dropdown {
z-index: 1000;
}
</style>
</svelte:head>
<div class="theme-switcher">
<button
class="theme-button"
on:click={toggle}
aria-label="Change theme"
aria-expanded={isOpen}
>
<span class="current-icon">
{#if $theme === 'light'}
☀️
{:else if $theme === 'dark'}
🌙
{:else}
💻
{/if}
</span>
</button>
{#if isOpen}
<div class="theme-dropdown">
{#each themes as t}
<button
class="theme-option"
class:active={$theme === t.value}
on:click={() => selectTheme(t.value)}
>
<span class="theme-icon">{t.icon}</span>
<span class="theme-label">{t.label}</span>
{#if $theme === t.value}
<span class="checkmark"></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.theme-switcher {
position: relative;
display: inline-block;
}
.theme-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius);
transition: background-color 0.2s;
color: var(--text-inverted);
font-size: 1.25rem;
}
.theme-button:hover {
background-color: var(--bg-hover);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
min-width: 140px;
overflow: hidden;
}
.theme-option {
width: 100%;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.9rem;
color: var(--text-primary);
transition: background-color 0.2s;
}
.theme-option:hover {
background-color: var(--bg-secondary);
}
.theme-option.active {
background-color: var(--bg-secondary);
font-weight: 600;
}
.theme-icon {
font-size: 1.1rem;
}
.theme-label {
flex: 1;
}
.checkmark {
color: var(--color-primary);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,56 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Theme store
* Manages theme state (light, dark, system) with localStorage persistence
*/
function createThemeStore() {
// Initialize state from localStorage
const initialState = browser ? localStorage.getItem('theme') || 'light' : 'light';
const { subscribe, set, update } = writable(initialState);
// Listen for system preference changes
let mediaQuery;
if (browser) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = () => {
// Trigger update to recompute isDark
update((n) => n);
};
mediaQuery.addEventListener('change', handleMediaChange);
}
// Derived store for whether dark mode should be active
const isDark = derived(initialState, ($theme) => {
if (!browser) return false;
if ($theme === 'dark') return true;
if ($theme === 'light') return false;
// system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
return {
subscribe,
isDark,
setTheme: (theme) => {
if (browser) {
localStorage.setItem('theme', theme);
// Apply data-theme attribute to document
const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
}
set(theme);
},
// Initialize theme on client-side
init: () => {
if (!browser) return;
const theme = localStorage.getItem('theme') || 'light';
const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
},
};
}
export const theme = createThemeStore();

View File

@@ -1,11 +1,20 @@
<script> <script>
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { theme } from '$lib/stores/theme.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { adminAPI, authAPI } from '$lib/api.js'; import { adminAPI, authAPI } from '$lib/api.js';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import '../app.css';
let stoppingImpersonation = false; let stoppingImpersonation = false;
// Initialize theme on mount
onMount(() => {
theme.init();
});
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
// Use hard redirect to ensure proper navigation after logout // Use hard redirect to ensure proper navigation after logout
@@ -62,6 +71,7 @@
{#if $auth.user?.isAdmin} {#if $auth.user?.isAdmin}
<a href="/admin" class="nav-link admin-link">Admin</a> <a href="/admin" class="nav-link admin-link">Admin</a>
{/if} {/if}
<ThemeSwitcher />
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button> <button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
</div> </div>
</div> </div>
@@ -111,7 +121,8 @@
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5; background-color: var(--bg-body);
color: var(--text-primary);
} }
.app { .app {
@@ -121,12 +132,12 @@
} }
.navbar { .navbar {
background-color: #2c3e50; background-color: var(--bg-navbar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-md);
} }
.nav-container { .nav-container {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 0 1rem; padding: 0 1rem;
display: flex; display: flex;
@@ -136,7 +147,7 @@
} }
.nav-brand .callsign { .nav-brand .callsign {
color: white; color: var(--text-inverted);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
@@ -148,11 +159,12 @@
} }
.nav-link { .nav-link {
color: rgba(255, 255, 255, 0.8); color: var(--text-inverted);
opacity: 0.8;
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 4px; border-radius: var(--border-radius);
transition: all 0.2s; transition: all 0.2s;
background: none; background: none;
border: none; border: none;
@@ -161,40 +173,41 @@
} }
.nav-link:hover { .nav-link:hover {
color: white; opacity: 1;
background-color: rgba(255, 255, 255, 0.1); background-color: var(--bg-hover);
} }
.logout-btn { .logout-btn {
color: #ff6b6b; color: var(--color-logout);
opacity: 1;
} }
.logout-btn:hover { .logout-btn:hover {
color: #ff5252; background-color: var(--color-logout-bg);
background-color: rgba(255, 107, 107, 0.1);
} }
.admin-link { .admin-link {
background-color: #ffc107; background-color: var(--color-admin-bg);
color: #000; color: var(--color-admin-text);
font-weight: 600; font-weight: 600;
} }
.admin-link:hover { .admin-link:hover {
background-color: #e0a800; background-color: var(--color-admin-hover);
} }
main { main {
flex: 1; flex: 1;
padding: 2rem 1rem; padding: 2rem 1rem;
max-width: 1200px; max-width: 1600px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
} }
footer { footer {
background-color: #2c3e50; background-color: var(--bg-footer);
color: rgba(255, 255, 255, 0.7); color: var(--text-inverted);
opacity: 0.7;
text-align: center; text-align: center;
padding: 1.5rem; padding: 1.5rem;
margin-top: auto; margin-top: auto;
@@ -207,13 +220,13 @@
/* Impersonation Banner */ /* Impersonation Banner */
.impersonation-banner { .impersonation-banner {
background-color: #fff3cd; background-color: var(--impersonation-bg);
border: 2px solid #ffc107; border: 2px solid var(--impersonation-border);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.impersonation-content { .impersonation-content {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -228,15 +241,15 @@
.impersonation-text { .impersonation-text {
flex: 1; flex: 1;
font-size: 0.95rem; font-size: 0.95rem;
color: #856404; color: var(--impersonation-text);
} }
.stop-impersonation-btn { .stop-impersonation-btn {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: var(--impersonation-text);
border: none; border: none;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
@@ -244,7 +257,7 @@
} }
.stop-impersonation-btn:hover:not(:disabled) { .stop-impersonation-btn:hover:not(:disabled) {
background-color: #e0a800; background-color: var(--color-warning-hover);
} }
.stop-impersonation-btn:disabled { .stop-impersonation-btn:disabled {

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { jobsAPI } from '$lib/api.js'; import { jobsAPI, autoSyncAPI } from '$lib/api.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
let jobs = []; let jobs = [];
@@ -9,6 +9,18 @@
let cancellingJobs = new Map(); // Track cancelling state per job let cancellingJobs = new Map(); // Track cancelling state per job
let pollingInterval = null; let pollingInterval = null;
// Auto-sync settings state
let autoSyncSettings = null;
let loadingAutoSync = false;
// Reactive: scheduled jobs derived from settings
// Note: Explicitly reference autoSyncSettings to ensure Svelte tracks it as a dependency
let scheduledJobs = [];
$: {
autoSyncSettings; // Touch variable so Svelte tracks reactivity
scheduledJobs = getScheduledJobs();
}
async function loadJobs() { async function loadJobs() {
try { try {
const response = await jobsAPI.getRecent(5); const response = await jobsAPI.getRecent(5);
@@ -22,6 +34,81 @@
} }
} }
async function loadAutoSyncSettings() {
try {
loadingAutoSync = true;
const response = await autoSyncAPI.getSettings();
autoSyncSettings = response.settings || null;
} catch (error) {
console.error('Failed to load auto-sync settings:', error);
// Don't show error, auto-sync is optional
} finally {
loadingAutoSync = false;
}
}
function getScheduledJobs() {
if (!autoSyncSettings) {
return [];
}
const scheduled = [];
if (autoSyncSettings.lotwEnabled) {
scheduled.push({
type: 'lotw_sync',
icon: '📡',
name: 'LoTW Auto-Sync',
interval: autoSyncSettings.lotwIntervalHours,
nextSyncAt: autoSyncSettings.lotwNextSyncAt,
lastSyncAt: autoSyncSettings.lotwLastSyncAt,
enabled: true,
});
}
if (autoSyncSettings.dclEnabled) {
scheduled.push({
type: 'dcl_sync',
icon: '🛰️',
name: 'DCL Auto-Sync',
interval: autoSyncSettings.dclIntervalHours,
nextSyncAt: autoSyncSettings.dclNextSyncAt,
lastSyncAt: autoSyncSettings.dclLastSyncAt,
enabled: true,
});
}
return scheduled;
}
function getNextSyncLabel(nextSyncAt, interval) {
if (!nextSyncAt) return 'Pending...';
const now = new Date();
const nextSync = new Date(nextSyncAt);
const diffMs = nextSync - now;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMs < 0) return 'Due now';
if (diffMins < 60) return `In ${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
return `In ${diffDays} day${diffDays !== 1 ? 's' : ''}`;
}
function formatNextSyncTime(nextSyncAt) {
if (!nextSyncAt) return null;
const date = new Date(nextSyncAt);
return date.toLocaleString();
}
function formatLastSyncTime(lastSyncAt) {
if (!lastSyncAt) return 'Never';
const date = new Date(lastSyncAt);
return formatDate(date);
}
function hasActiveJobs() { function hasActiveJobs() {
return jobs.some(job => job.status === 'pending' || job.status === 'running'); return jobs.some(job => job.status === 'pending' || job.status === 'running');
} }
@@ -58,6 +145,7 @@
// Load recent jobs if authenticated // Load recent jobs if authenticated
if ($auth.user) { if ($auth.user) {
await loadJobs(); await loadJobs();
await loadAutoSyncSettings();
loading = false; loading = false;
} }
}); });
@@ -187,6 +275,47 @@
</div> </div>
</div> </div>
<!-- Scheduled Auto-Sync Jobs -->
{#if scheduledJobs.length > 0}
<div class="scheduled-section">
<h2 class="section-title">⏰ Upcoming Auto-Sync</h2>
<div class="jobs-list">
{#each scheduledJobs as scheduled (scheduled.type)}
<div class="job-card job-card-scheduled">
<div class="job-header">
<div class="job-title">
<span class="job-icon">{scheduled.icon}</span>
<span class="job-name">{scheduled.name}</span>
<span class="job-badge scheduled-badge">Scheduled</span>
</div>
<span class="scheduled-interval">Every {scheduled.interval}h</span>
</div>
<div class="job-meta">
<span class="job-date">
Next: <strong title={formatNextSyncTime(scheduled.nextSyncAt)}>
{getNextSyncLabel(scheduled.nextSyncAt, scheduled.interval)}
</strong>
</span>
<span class="job-time">
Last: {formatLastSyncTime(scheduled.lastSyncAt)}
</span>
</div>
<div class="scheduled-countdown">
<div class="countdown-bar">
<div class="countdown-progress"></div>
</div>
<p class="countdown-text">
{formatNextSyncTime(scheduled.nextSyncAt)}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Recent Sync Jobs --> <!-- Recent Sync Jobs -->
<div class="jobs-section"> <div class="jobs-section">
<h2 class="section-title">🔄 Recent Sync Jobs</h2> <h2 class="section-title">🔄 Recent Sync Jobs</h2>
@@ -316,13 +445,13 @@
.welcome h1 { .welcome h1 {
font-size: 2.5rem; font-size: 2.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.subtitle { .subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: #666; color: var(--text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -335,12 +464,12 @@
.dashboard h1 { .dashboard h1 {
font-size: 2rem; font-size: 2rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.welcome-section p { .welcome-section p {
color: #666; color: var(--text-secondary);
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -353,21 +482,21 @@
} }
.action-card { .action-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.action-card h3 { .action-card h3 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #333; color: var(--text-primary);
} }
.action-card p { .action-card p {
color: #666; color: var(--text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -375,7 +504,7 @@
display: inline-block; display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
@@ -384,25 +513,25 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.action-card .btn { .action-card .btn {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
width: 100%; width: 100%;
text-align: center; text-align: center;
@@ -410,25 +539,25 @@
} }
.action-card .btn:hover { .action-card .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.info-box { .info-box {
background: #f8f9fa; background: var(--bg-secondary);
border-left: 4px solid #4a90e2; border-left: 4px solid var(--color-primary);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-box h3 { .info-box h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
} }
.info-box ol { .info-box ol {
margin: 0; margin: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
color: #666; color: var(--text-secondary);
line-height: 1.8; line-height: 1.8;
} }
@@ -439,18 +568,18 @@
.section-title { .section-title {
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.loading-state, .loading-state,
.empty-state { .empty-state {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: #666; color: var(--text-secondary);
} }
.empty-actions { .empty-actions {
@@ -468,20 +597,25 @@
} }
.job-card { .job-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
} }
.job-card:hover { .job-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); box-shadow: var(--shadow-md);
} }
.job-card.failed { .job-card.failed {
border-left: 4px solid #dc3545; border-left: 4px solid var(--color-error);
}
.job-card-scheduled {
border-left: 4px solid var(--badge-purple-bg);
background: var(--gradient-scheduled);
} }
.job-header { .job-header {
@@ -503,69 +637,83 @@
.job-name { .job-name {
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 1.1rem; font-size: 1.1rem;
} }
.job-id { .job-id {
font-size: 0.85rem; font-size: 0.85rem;
color: #999; color: var(--text-muted);
font-family: monospace; font-family: monospace;
} }
.status-badge { .status-badge {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--border-radius-pill);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
text-transform: capitalize; text-transform: capitalize;
} }
.bg-yellow-100 { .bg-yellow-100 {
background-color: #fef3c7; background-color: var(--badge-pending-bg);
} }
.bg-blue-100 { .bg-blue-100 {
background-color: #dbeafe; background-color: var(--badge-running-bg);
} }
.bg-green-100 { .bg-green-100 {
background-color: #d1fae5; background-color: var(--badge-completed-bg);
} }
.bg-red-100 { .bg-red-100 {
background-color: #fee2e2; background-color: var(--badge-failed-bg);
} }
.text-yellow-800 { .text-yellow-800 {
color: #92400e; color: var(--badge-pending-text);
} }
.text-blue-800 { .text-blue-800 {
color: #1e40af; color: var(--badge-running-text);
} }
.text-green-800 { .text-green-800 {
color: #065f46; color: var(--badge-completed-text);
} }
.text-red-800 { .text-red-800 {
color: #991b1b; color: var(--badge-failed-text);
} }
.bg-purple-100 { .bg-purple-100 {
background-color: #f3e8ff; background-color: var(--badge-cancelled-bg);
} }
.text-purple-800 { .text-purple-800 {
color: #6b21a8; color: var(--badge-cancelled-text);
}
.job-badge {
padding: 0.2rem 0.6rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.scheduled-badge {
background-color: var(--badge-purple-bg);
color: var(--badge-purple-text);
} }
.job-meta { .job-meta {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
color: #666; color: var(--text-secondary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -576,14 +724,14 @@
.job-time, .job-time,
.job-duration { .job-duration {
color: #999; color: var(--text-muted);
} }
.job-error { .job-error {
background: #fee2e2; background: var(--color-error-bg);
color: #991b1b; color: var(--color-error-text);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.95rem; font-size: 0.95rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -597,29 +745,29 @@
.stat-item { .stat-item {
font-size: 0.9rem; font-size: 0.9rem;
color: #666; color: var(--text-secondary);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: #f8f9fa; background: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
} }
.stat-item strong { .stat-item strong {
color: #333; color: var(--text-primary);
} }
.stat-added { .stat-added {
color: #065f46; color: var(--color-success);
background: #d1fae5; background: var(--color-success-bg);
} }
.stat-updated { .stat-updated {
color: #1e40af; color: var(--color-info);
background: #dbeafe; background: var(--color-info-bg);
} }
.stat-skipped { .stat-skipped {
color: #92400e; color: var(--badge-pending-text);
background: #fef3c7; background: var(--badge-pending-bg);
} }
.job-progress { .job-progress {
@@ -627,7 +775,7 @@
} }
.progress-text { .progress-text {
color: #1e40af; color: var(--color-info);
font-size: 0.9rem; font-size: 0.9rem;
font-style: italic; font-style: italic;
} }
@@ -641,17 +789,17 @@
.btn-cancel { .btn-cancel {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
font-size: 0.85rem; font-size: 0.85rem;
border: 1px solid #dc3545; border: 1px solid var(--color-error);
background: white; background: var(--bg-card);
color: #dc3545; color: var(--color-error);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-weight: 500; font-weight: 500;
} }
.btn-cancel:hover:not(:disabled) { .btn-cancel:hover:not(:disabled) {
background: #dc3545; background: var(--color-error);
color: white; color: white;
} }
@@ -659,4 +807,53 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* Scheduled Jobs Section */
.scheduled-section {
margin-bottom: 2rem;
}
/* Scheduled job countdown */
.scheduled-countdown {
margin-top: 1rem;
}
.countdown-bar {
height: 6px;
background: var(--border-color-light);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.countdown-progress {
height: 100%;
background: var(--gradient-purple);
border-radius: 3px;
width: 100%;
animation: pulse-countdown 2s ease-in-out infinite;
}
@keyframes pulse-countdown {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.countdown-text {
margin: 0;
font-size: 0.85rem;
color: var(--badge-purple-bg);
text-align: center;
font-weight: 500;
}
@media (max-width: 640px) {
.scheduled-list {
grid-template-columns: 1fr;
}
}
</style> </style>

View File

@@ -12,7 +12,7 @@
let impersonationStatus = null; let impersonationStatus = null;
// UI state // UI state
let selectedTab = 'overview'; // 'overview', 'users', 'actions' let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
let showImpersonationModal = false; let showImpersonationModal = false;
let showDeleteUserModal = false; let showDeleteUserModal = false;
let showRoleChangeModal = false; let showRoleChangeModal = false;
@@ -141,19 +141,19 @@
} }
} }
async function handleRoleChange(userId, newIsAdmin) { async function handleRoleChange(userId, newRole) {
try { try {
loading = true; loading = true;
const data = await adminAPI.updateUserRole(userId, newIsAdmin); const data = await adminAPI.updateUserRole(userId, newRole);
if (data.success) { if (data.success) {
alert(data.message); alert(data.message);
await loadUsers(); await loadUsers();
} else { } else {
alert('Failed to update user admin status: ' + (data.error || 'Unknown error')); alert('Failed to update user role: ' + (data.error || 'Unknown error'));
} }
} catch (err) { } catch (err) {
alert('Failed to update user admin status: ' + err.message); alert('Failed to update user role: ' + err.message);
} finally { } finally {
loading = false; loading = false;
showRoleChangeModal = false; showRoleChangeModal = false;
@@ -197,7 +197,8 @@
user.callsign.toLowerCase().includes(userSearch.toLowerCase()); user.callsign.toLowerCase().includes(userSearch.toLowerCase());
const matchesFilter = userFilter === 'all' || const matchesFilter = userFilter === 'all' ||
(userFilter === 'admin' && user.isAdmin) || (userFilter === 'super-admin' && user.isSuperAdmin) ||
(userFilter === 'admin' && user.isAdmin && !user.isSuperAdmin) ||
(userFilter === 'user' && !user.isAdmin); (userFilter === 'user' && !user.isAdmin);
return matchesSearch && matchesFilter; return matchesSearch && matchesFilter;
@@ -226,6 +227,12 @@
> >
Users Users
</button> </button>
<button
class="tab {selectedTab === 'awards' ? 'active' : ''}"
on:click={() => selectedTab = 'awards'}
>
Awards
</button>
<button <button
class="tab {selectedTab === 'actions' ? 'active' : ''}" class="tab {selectedTab === 'actions' ? 'active' : ''}"
on:click={() => selectedTab = 'actions'} on:click={() => selectedTab = 'actions'}
@@ -311,6 +318,7 @@
/> />
<select class="filter-select" bind:value={userFilter}> <select class="filter-select" bind:value={userFilter}>
<option value="all">All Users</option> <option value="all">All Users</option>
<option value="super-admin">Super Admins Only</option>
<option value="admin">Admins Only</option> <option value="admin">Admins Only</option>
<option value="user">Regular Users Only</option> <option value="user">Regular Users Only</option>
</select> </select>
@@ -330,6 +338,7 @@
<th>DCL Conf.</th> <th>DCL Conf.</th>
<th>Total Conf.</th> <th>Total Conf.</th>
<th>Last Sync</th> <th>Last Sync</th>
<th>Last Seen</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -340,8 +349,8 @@
<td>{user.email}</td> <td>{user.email}</td>
<td>{user.callsign}</td> <td>{user.callsign}</td>
<td> <td>
<span class="role-badge {user.isAdmin ? 'admin' : 'user'}"> <span class="role-badge {user.isSuperAdmin ? 'super-admin' : (user.isAdmin ? 'admin' : 'user')}">
{user.isAdmin ? 'Admin' : 'User'} {user.isSuperAdmin ? 'Super Admin' : (user.isAdmin ? 'Admin' : 'User')}
</span> </span>
</td> </td>
<td>{user.qsoCount || 0}</td> <td>{user.qsoCount || 0}</td>
@@ -349,11 +358,12 @@
<td>{user.dclConfirmed || 0}</td> <td>{user.dclConfirmed || 0}</td>
<td>{user.totalConfirmed || 0}</td> <td>{user.totalConfirmed || 0}</td>
<td>{formatDate(user.lastSync)}</td> <td>{formatDate(user.lastSync)}</td>
<td>{formatDate(user.lastSeen)}</td>
<td class="actions-cell"> <td class="actions-cell">
<button <button
class="action-button impersonate-btn" class="action-button impersonate-btn"
on:click={() => openImpersonationModal(user)} on:click={() => openImpersonationModal(user)}
disabled={user.isAdmin} disabled={user.isAdmin && !$auth.user.isSuperAdmin}
> >
Impersonate Impersonate
</button> </button>
@@ -382,6 +392,30 @@
</div> </div>
{/if} {/if}
<!-- Awards Tab -->
{#if selectedTab === 'awards'}
<div class="tab-content">
<h2>Award Definitions</h2>
<p class="help-text">Manage award definitions. Create, edit, and delete awards.</p>
<div class="awards-quick-actions">
<a href="/admin/awards" class="btn btn-primary">Manage Awards</a>
</div>
<div class="awards-info">
<h3>Award Management</h3>
<p>From the Awards management page, you can:</p>
<ul>
<li><strong>Create</strong> new award definitions</li>
<li><strong>Edit</strong> existing award definitions</li>
<li><strong>Delete</strong> awards</li>
<li><strong>Test</strong> award calculations with sample user data</li>
</ul>
<p>All award definitions are stored as JSON files in the <code>award-definitions/</code> directory.</p>
</div>
</div>
{/if}
<!-- Actions Tab --> <!-- Actions Tab -->
{#if selectedTab === 'actions'} {#if selectedTab === 'actions'}
<div class="tab-content"> <div class="tab-content">
@@ -463,25 +497,34 @@
<div class="modal-content" on:click|stopPropagation> <div class="modal-content" on:click|stopPropagation>
<h2>Change User Role</h2> <h2>Change User Role</h2>
<p>User: <strong>{selectedUser.email}</strong></p> <p>User: <strong>{selectedUser.email}</strong></p>
<p>Current Role: <strong>{selectedUser.isAdmin ? 'Admin' : 'User'}</strong></p> <p>Current Role: <strong>{selectedUser.isSuperAdmin ? 'Super Admin' : (selectedUser.isAdmin ? 'Admin' : 'User')}</strong></p>
<p>New Role:</p> <p>New Role:</p>
<div class="role-options"> <div class="role-options">
<label> <label>
<input type="radio" name="role" value="user" checked={!selectedUser.isAdmin} /> <input type="radio" name="role" value="user" checked={!selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
Regular User Regular User
</label> </label>
<label> <label>
<input type="radio" name="role" value="admin" checked={selectedUser.isAdmin} /> <input type="radio" name="role" value="admin" checked={selectedUser.isAdmin && !selectedUser.isSuperAdmin} />
Admin Admin
</label> </label>
{#if $auth.user.isSuperAdmin}
<label>
<input type="radio" name="role" value="super-admin" checked={selectedUser.isSuperAdmin} />
Super Admin
</label>
{/if}
</div> </div>
{#if !$auth.user.isSuperAdmin && (selectedUser.isSuperAdmin || (selectedUser.isAdmin && selectedUser.email !== selectedUser.email))}
<p class="warning">Note: Only super-admins can promote or demote super-admins.</p>
{/if}
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-button cancel" on:click={() => showRoleChangeModal = false}> <button class="modal-button cancel" on:click={() => showRoleChangeModal = false}>
Cancel Cancel
</button> </button>
<button <button
class="modal-button confirm" class="modal-button confirm"
on:click={() => handleRoleChange(selectedUser.id, !selectedUser.isAdmin)} on:click={() => handleRoleChange(selectedUser.id, document.querySelector('input[name="role"]:checked')?.value || 'user')}
> >
Change Role Change Role
</button> </button>
@@ -518,7 +561,7 @@
<style> <style>
.admin-dashboard { .admin-dashboard {
padding: 2rem; padding: 2rem;
max-width: 1400px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -526,19 +569,20 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--text-secondary);
} }
.error { .error {
background-color: #fee; background-color: var(--color-error-bg);
border: 1px solid #fcc; border: 1px solid var(--color-error);
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
color: #c00; color: var(--color-error-text);
} }
h1 { h1 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #333; color: var(--text-primary);
} }
/* Tabs */ /* Tabs */
@@ -546,7 +590,7 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
border-bottom: 2px solid #ddd; border-bottom: 2px solid var(--border-color);
} }
.tab { .tab {
@@ -556,30 +600,30 @@
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #666; color: var(--text-secondary);
position: relative; position: relative;
top: 2px; top: 2px;
} }
.tab:hover { .tab:hover {
color: #333; color: var(--text-primary);
} }
.tab.active { .tab.active {
color: #007bff; color: var(--color-primary);
border-bottom: 2px solid #007bff; border-bottom: 2px solid var(--color-primary);
} }
.tab-content { .tab-content {
background: white; background: var(--bg-card);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
h2 { h2 {
margin-bottom: 1rem; margin-bottom: 1rem;
color: #333; color: var(--text-primary);
} }
/* Stats Grid */ /* Stats Grid */
@@ -594,8 +638,8 @@
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.stat-card h3 { .stat-card h3 {
@@ -638,9 +682,11 @@
.search-input, .search-input,
.filter-select { .filter-select {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
@@ -655,8 +701,8 @@
.users-table { .users-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@@ -664,23 +710,23 @@
.users-table td { .users-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.users-table th { .users-table th {
background-color: #f5f5f5; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.users-table tr:hover { .users-table tr:hover {
background-color: #f9f9f9; background-color: var(--bg-secondary);
} }
.admin-row { .admin-row {
background-color: #fff9e6 !important; background-color: var(--color-warning-bg) !important;
} }
.actions-cell { .actions-cell {
@@ -691,7 +737,7 @@
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
margin-right: 0.3rem; margin-right: 0.3rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
transition: all 0.2s; transition: all 0.2s;
@@ -712,21 +758,21 @@
} }
.role-btn { .role-btn {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: #000;
} }
.role-btn:hover:not(:disabled) { .role-btn:hover:not(:disabled) {
background-color: #e0a800; background-color: var(--color-warning-hover);
} }
.delete-btn { .delete-btn {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.delete-btn:hover:not(:disabled) { .delete-btn:hover:not(:disabled) {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.role-badge { .role-badge {
@@ -741,14 +787,19 @@
color: white; color: white;
} }
.role-badge.super-admin {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.role-badge.user { .role-badge.user {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.users-count { .users-count {
margin-top: 1rem; margin-top: 1rem;
color: #666; color: var(--text-secondary);
font-style: italic; font-style: italic;
} }
@@ -760,8 +811,8 @@
.actions-table { .actions-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white; background: var(--bg-card);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
} }
@@ -769,15 +820,15 @@
.actions-table td { .actions-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.actions-table th { .actions-table th {
background-color: #f5f5f5; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.8rem; font-size: 0.8rem;
color: #666; color: var(--text-secondary);
} }
.action-type { .action-type {
@@ -788,12 +839,12 @@
} }
.action-type.impersonate_start { .action-type.impersonate_start {
background-color: #ffc107; background-color: var(--color-warning);
color: #000; color: #000;
} }
.action-type.impersonate_stop { .action-type.impersonate_stop {
background-color: #28a745; background-color: var(--color-success-light);
color: white; color: white;
} }
@@ -803,14 +854,14 @@
} }
.action-type.user_delete { .action-type.user_delete {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.details-json { .details-json {
background-color: #f5f5f5; background-color: var(--bg-secondary);
padding: 0.5rem; padding: 0.5rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.8rem; font-size: 0.8rem;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
@@ -819,7 +870,7 @@
.no-actions { .no-actions {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
color: #666; color: var(--text-secondary);
} }
/* Modal */ /* Modal */
@@ -837,30 +888,30 @@
} }
.modal-content { .modal-content {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
.modal-content h2 { .modal-content h2 {
margin-top: 0; margin-top: 0;
color: #333; color: var(--text-primary);
} }
.modal-content p { .modal-content p {
color: #666; color: var(--text-secondary);
line-height: 1.5; line-height: 1.5;
} }
.modal-content .warning { .modal-content .warning {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
border-left: 4px solid #ffc107; border-left: 4px solid var(--color-warning);
padding: 1rem; padding: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #856404; color: var(--badge-pending-text);
} }
.modal-actions { .modal-actions {
@@ -873,36 +924,36 @@
.modal-button { .modal-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
} }
.modal-button.cancel { .modal-button.cancel {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.modal-button.cancel:hover { .modal-button.cancel:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.modal-button.confirm { .modal-button.confirm {
background-color: #007bff; background-color: var(--color-primary);
color: white; color: white;
} }
.modal-button.confirm:hover { .modal-button.confirm:hover {
background-color: #0056b3; background-color: var(--color-primary-hover);
} }
.modal-button.delete-confirm { .modal-button.delete-confirm {
background-color: #dc3545; background-color: var(--color-error);
} }
.modal-button.delete-confirm:hover { .modal-button.delete-confirm:hover {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.role-options { .role-options {
@@ -919,6 +970,50 @@
cursor: pointer; cursor: pointer;
} }
.help-text {
color: var(--text-secondary);
font-style: italic;
margin-bottom: 1rem;
}
.awards-quick-actions {
margin-bottom: 2rem;
}
.awards-info {
background-color: var(--bg-secondary);
border-left: 4px solid #667eea;
padding: 1.5rem;
border-radius: var(--border-radius);
}
.awards-info h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--text-primary);
}
.awards-info p {
margin-bottom: 1rem;
}
.awards-info ul {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.awards-info li {
margin-bottom: 0.5rem;
}
.awards-info code {
background-color: var(--border-color);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.9rem;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.users-header { .users-header {
flex-direction: column; flex-direction: column;

View File

@@ -0,0 +1,381 @@
<script>
import { onMount } from 'svelte';
import { auth } from '$lib/stores.js';
import { awardsAdminAPI } from '$lib/api.js';
import { browser } from '$app/environment';
let loading = true;
let error = null;
let awards = [];
let searchQuery = '';
let categoryFilter = 'all';
onMount(async () => {
if (!$auth.user) {
window.location.href = '/auth/login';
return;
}
if (!$auth.user.isAdmin) {
error = 'Admin access required';
loading = false;
return;
}
await loadAwards();
loading = false;
});
async function loadAwards() {
try {
const data = await awardsAdminAPI.getAll();
awards = data.awards || [];
} catch (err) {
error = err.message;
}
}
async function handleDelete(id) {
const award = awards.find(a => a.id === id);
if (!award) return;
if (!confirm(`Are you sure you want to delete award "${award.name}"?\n\nThis action cannot be undone.`)) {
return;
}
try {
loading = true;
await awardsAdminAPI.delete(id);
await loadAwards();
} catch (err) {
alert('Failed to delete award: ' + err.message);
} finally {
loading = false;
}
}
function getRuleTypeDisplayName(ruleType) {
const names = {
'entity': 'Entity',
'dok': 'DOK',
'points': 'Points',
'filtered': 'Filtered',
'counter': 'Counter',
'wae': 'WAE'
};
return names[ruleType] || ruleType;
}
function getCategoryColor(category) {
const colors = {
'dxcc': 'purple',
'darc': 'orange',
'vucc': 'blue',
'was': 'green',
'special': 'red',
};
return colors[category] || 'gray';
}
$: filteredAwards = awards.filter(award => {
const matchesSearch = !searchQuery ||
award.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
award.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
award.category.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = categoryFilter === 'all' || award.category === categoryFilter;
return matchesSearch && matchesCategory;
});
$: categories = [...new Set(awards.map(a => a.category))].sort();
</script>
{#if loading && awards.length === 0}
<div class="loading">Loading award definitions...</div>
{:else if error}
<div class="error">{error}</div>
{:else}
<div class="awards-admin">
<div class="header">
<h1>Award Definitions</h1>
<a href="/admin/awards/create" class="btn btn-primary">Create New Award</a>
</div>
<div class="filters">
<input
type="text"
class="search-input"
placeholder="Search by name, ID, or category..."
bind:value={searchQuery}
/>
<select class="category-filter" bind:value={categoryFilter}>
<option value="all">All Categories</option>
{#each categories as category}
<option value={category}>{category}</option>
{/each}
</select>
</div>
<div class="awards-table-container">
<table class="awards-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Rule Type</th>
<th>Target</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each filteredAwards as award}
<tr>
<td class="id-cell">{award.id}</td>
<td>
<div class="name-cell">
<strong>{award.name}</strong>
<small>{award.description}</small>
</div>
</td>
<td>
<span class="category-badge {getCategoryColor(award.category)}">
{award.category}
</span>
</td>
<td>{getRuleTypeDisplayName(award.rules.type)}</td>
<td>{award.rules.target || '-'}</td>
<td class="actions-cell">
<a href="/admin/awards/{award.id}" class="action-btn edit-btn">Edit</a>
<a href="/awards/{award.id}" target="_blank" class="action-btn view-btn">View</a>
<button
class="action-btn delete-btn"
on:click={() => handleDelete(award.id)}
>
Delete
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="count">Showing {filteredAwards.length} award(s)</p>
</div>
{/if}
<style>
.awards-admin {
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
}
.loading {
text-align: center;
padding: 3rem;
font-size: 1.2rem;
color: var(--text-secondary);
}
.error {
background-color: var(--color-error-bg);
border: 1px solid var(--color-error);
padding: 1rem;
border-radius: var(--border-radius);
color: var(--color-error);
margin: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.header h1 {
margin: 0;
color: var(--text-primary);
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-input,
.category-filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
}
.search-input {
flex: 1;
min-width: 250px;
}
.category-filter {
min-width: 150px;
}
.btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--color-primary);
color: var(--text-inverted);
}
.btn-primary:hover {
background-color: var(--color-primary-hover);
}
.awards-table-container {
overflow-x: auto;
background: var(--bg-card);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
}
.awards-table {
width: 100%;
border-collapse: collapse;
}
.awards-table th,
.awards-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
.awards-table th {
background-color: var(--bg-hover);
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
color: var(--text-secondary);
}
.awards-table tr:hover {
background-color: var(--bg-secondary);
}
.id-cell {
font-family: monospace;
color: var(--text-secondary);
font-size: 0.9rem;
}
.name-cell {
display: flex;
flex-direction: column;
}
.name-cell small {
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.category-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
display: inline-block;
}
.category-badge.purple { background-color: #9b59b6; color: white; }
.category-badge.orange { background-color: #e67e22; color: white; }
.category-badge.blue { background-color: #3498db; color: white; }
.category-badge.green { background-color: #27ae60; color: white; }
.category-badge.red { background-color: #e74c3c; color: white; }
.category-badge.gray { background-color: #95a5a6; color: white; }
.actions-cell {
white-space: nowrap;
}
.action-btn {
padding: 0.4rem 0.8rem;
margin-right: 0.3rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.85rem;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
color: var(--text-inverted);
}
.edit-btn {
background-color: #3498db;
}
.edit-btn:hover {
background-color: #2980b9;
}
.view-btn {
background-color: #27ae60;
}
.view-btn:hover {
background-color: #219a52;
}
.delete-btn {
background-color: #e74c3c;
}
.delete-btn:hover {
background-color: #c0392b;
}
.count {
margin-top: 1rem;
color: var(--text-secondary);
font-style: italic;
}
@media (max-width: 768px) {
.awards-admin {
padding: 1rem;
}
.header {
flex-direction: column;
align-items: stretch;
}
.filters {
flex-direction: column;
}
.search-input {
width: 100%;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,486 @@
<script>
import { createEventDispatcher } from 'svelte';
export let filters = null;
export let onChange = () => {};
const dispatch = createEventDispatcher();
const ALL_FIELDS = [
{ value: 'band', label: 'Band' },
{ value: 'mode', label: 'Mode' },
{ value: 'callsign', label: 'Callsign' },
{ value: 'entity', label: 'Entity (Country)' },
{ value: 'entityId', label: 'Entity ID' },
{ value: 'state', label: 'State' },
{ value: 'grid', label: 'Grid Square' },
{ value: 'satName', label: 'Satellite Name' },
{ value: 'satellite', label: 'Is Satellite QSO' },
];
const OPERATORS = [
{ value: 'eq', label: 'Equals', needsArray: false },
{ value: 'ne', label: 'Not Equals', needsArray: false },
{ value: 'in', label: 'In', needsArray: true },
{ value: 'nin', label: 'Not In', needsArray: true },
{ value: 'contains', label: 'Contains', needsArray: false },
];
const BAND_OPTIONS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
const MODE_OPTIONS = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
// Add a new filter
function addFilter() {
if (!filters) {
filters = { operator: 'AND', filters: [] };
}
if (!filters.filters) {
filters.filters = [];
}
filters.filters.push({
field: 'band',
operator: 'eq',
value: ''
});
updateFilters();
}
// Add a nested filter group
function addFilterGroup() {
if (!filters) {
filters = { operator: 'AND', filters: [] };
}
if (!filters.filters) {
filters.filters = [];
}
filters.filters.push({
operator: 'AND',
filters: []
});
updateFilters();
}
// Remove a filter at index
function removeFilter(index) {
if (filters && filters.filters) {
filters.filters.splice(index, 1);
// If no filters left, set to null
if (filters.filters.length === 0) {
filters = null;
}
updateFilters();
}
}
// Update a filter at index
function updateFilter(index, key, value) {
if (filters && filters.filters) {
filters.filters[index][key] = value;
updateFilters();
}
}
// Update filter operator (AND/OR)
function updateOperator(operator) {
if (filters) {
filters.operator = operator;
updateFilters();
}
}
// Get input type based on field and operator
function getInputType(field, operator) {
const opConfig = OPERATORS.find(o => o.value === operator);
const needsArray = opConfig?.needsArray || false;
if (field === 'band' && needsArray) return 'band-multi';
if (field === 'band') return 'band';
if (field === 'mode' && needsArray) return 'mode-multi';
if (field === 'mode') return 'mode';
if (field === 'satellite') return 'boolean';
if (needsArray) return 'text-array';
return 'text';
}
// Notify parent of changes
function updateFilters() {
// Deep clone to avoid reactivity issues
const cloned = filters ? JSON.parse(JSON.stringify(filters)) : null;
onChange(cloned);
dispatch('change', cloned);
}
// Check if a filter is a group (has nested filters)
function isFilterGroup(filter) {
return filter && typeof filter === 'object' && filter.filters !== undefined;
}
</script>
<div class="filter-builder">
{#if !filters || !filters.filters || filters.filters.length === 0}
<div class="no-filters">
<p>No filters defined. All QSOs will be evaluated.</p>
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
</div>
{:else}
<div class="filter-group">
<div class="filter-group-header">
<h4>Filters</h4>
<div class="operator-selector">
<label>
<input
type="radio"
bind:group={filters.operator}
value="AND"
on:change={() => updateOperator('AND')}
/>
AND
</label>
<label>
<input
type="radio"
bind:group={filters.operator}
value="OR"
on:change={() => updateOperator('OR')}
/>
OR
</label>
</div>
</div>
<div class="filter-list">
{#each filters.filters as filter, index}
<div class="filter-item">
{#if isFilterGroup(filter)}
<!-- Nested filter group -->
<div class="nested-filter-group">
<div class="nested-header">
<span class="group-label">Group ({filter.operator})</span>
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
</div>
<svelte:self filters={filter} onChange={(nested) => updateFilter(index, nested)} />
</div>
{:else}
<!-- Single filter -->
<div class="single-filter">
<select
class="field-select"
bind:value={filter.field}
on:change={() => updateFilter(index, 'field', filter.field)}
>
{#each ALL_FIELDS as field}
<option value={field.value}>{field.label}</option>
{/each}
</select>
<select
class="operator-select"
bind:value={filter.operator}
on:change={() => updateFilter(index, 'operator', filter.operator)}
>
{#each OPERATORS as op}
<option value={op.value}>{op.label}</option>
{/each}
</select>
<div class="filter-value">
{#if getInputType(filter.field, filter.operator) === 'band'}
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
<option value="">Select band</option>
{#each BAND_OPTIONS as band}
<option value={band}>{band}</option>
{/each}
</select>
{:else if getInputType(filter.field, filter.operator) === 'band-multi'}
<div class="multi-select">
{#each BAND_OPTIONS as band}
<label class="checkbox-option">
<input
type="checkbox"
checked={Array.isArray(filter.value) && filter.value.includes(band)}
on:change={(e) => {
if (!Array.isArray(filter.value)) filter.value = [];
if (e.target.checked) {
filter.value = [...filter.value, band];
} else {
filter.value = filter.value.filter(v => v !== band);
}
updateFilter(index, 'value', filter.value);
}}
/>
{band}
</label>
{/each}
</div>
{:else if getInputType(filter.field, filter.operator) === 'mode'}
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
<option value="">Select mode</option>
{#each MODE_OPTIONS as mode}
<option value={mode}>{mode}</option>
{/each}
</select>
{:else if getInputType(filter.field, filter.operator) === 'mode-multi'}
<div class="multi-select">
{#each MODE_OPTIONS as mode}
<label class="checkbox-option">
<input
type="checkbox"
checked={Array.isArray(filter.value) && filter.value.includes(mode)}
on:change={(e) => {
if (!Array.isArray(filter.value)) filter.value = [];
if (e.target.checked) {
filter.value = [...filter.value, mode];
} else {
filter.value = filter.value.filter(v => v !== mode);
}
updateFilter(index, 'value', filter.value);
}}
/>
{mode}
</label>
{/each}
</div>
{:else if getInputType(filter.field, filter.operator) === 'boolean'}
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
{:else if getInputType(filter.field, filter.operator) === 'text-array'}
<input
type="text"
placeholder="comma-separated values"
value={Array.isArray(filter.value) ? filter.value.join(', ') : filter.value}
on:change={(e) => {
const values = e.target.value.split(',').map(v => v.trim()).filter(v => v);
updateFilter(index, 'value', values);
}}
/>
{:else}
<input
type="text"
placeholder="Value"
bind:value={filter.value}
on:change={() => updateFilter(index, 'value', filter.value)}
/>
{/if}
</div>
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
</div>
{/if}
</div>
{/each}
</div>
<div class="filter-actions">
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
<button class="btn btn-secondary" on:click={addFilterGroup}>Add Filter Group</button>
<button class="btn btn-danger" on:click={() => { filters = null; updateFilters(); }}>Clear All Filters</button>
</div>
</div>
{/if}
</div>
<style>
.filter-builder {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1.5rem;
background: var(--bg-secondary);
}
.no-filters {
text-align: center;
padding: 1rem;
color: var(--text-secondary);
}
.filter-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.filter-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.filter-group-header h4 {
margin: 0;
color: var(--text-primary);
}
.operator-selector {
display: flex;
gap: 1rem;
}
.operator-selector label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: var(--text-primary);
}
.filter-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.filter-item {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1rem;
}
.single-filter {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.single-filter select,
.single-filter input {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
}
.field-select {
min-width: 150px;
}
.operator-select {
min-width: 120px;
}
.filter-value {
flex: 1;
min-width: 200px;
}
.filter-value select,
.filter-value input {
width: 100%;
}
.nested-filter-group {
border-left: 3px solid var(--color-primary);
padding-left: 1rem;
}
.nested-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.group-label {
font-weight: 500;
color: var(--color-primary);
}
.multi-select {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.checkbox-option {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--bg-hover);
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.85rem;
color: var(--text-primary);
}
.checkbox-option input {
cursor: pointer;
}
.btn-remove {
background-color: var(--color-error);
color: var(--text-inverted);
padding: 0.4rem 0.8rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.85rem;
}
.btn-remove:hover {
background-color: #c82333;
}
.filter-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-inverted);
}
.btn-secondary {
background-color: #6c757d;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: var(--color-error);
}
.btn-danger:hover {
background-color: #c82333;
}
@media (max-width: 768px) {
.single-filter {
flex-direction: column;
align-items: stretch;
}
.field-select,
.operator-select,
.filter-value {
width: 100%;
min-width: unset;
}
.filter-actions {
flex-direction: column;
}
.filter-actions button {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,847 @@
<script>
import { onMount } from 'svelte';
import { awardsAdminAPI, adminAPI } from '$lib/api.js';
export let awardId = null;
export let awardDefinition = null;
export let onClose = () => {};
let loading = false;
let testResult = null;
let testError = null;
let users = [];
let selectedUserId = null;
// Extended validation results
let logicValidation = null;
onMount(async () => {
await loadUsers();
if (awardDefinition) {
performLogicValidation();
}
});
async function loadUsers() {
try {
const data = await adminAPI.getUsers();
users = data.users || [];
} catch (err) {
console.error('Failed to load users:', err);
}
}
async function runTest() {
if (!awardId) return;
loading = true;
testResult = null;
testError = null;
try {
// Pass awardDefinition for unsaved awards (testing during create/edit)
const data = await awardsAdminAPI.test(awardId, selectedUserId, awardDefinition);
testResult = data;
} catch (err) {
testError = err.message;
} finally {
loading = false;
}
}
// Perform deep logic validation on award definition
function performLogicValidation() {
if (!awardDefinition) return;
const issues = {
errors: [],
warnings: [],
info: []
};
const rules = awardDefinition.rules;
// 1. Check for impossible filter combinations
if (rules.filters) {
const impossibleFilterCombos = checkImpossibleFilters(rules.filters, rules);
issues.errors.push(...impossibleFilterCombos.errors);
issues.warnings.push(...impossibleFilterCombos.warnings);
}
// 2. Check for redundancy
const redundancies = checkRedundancies(rules);
issues.warnings.push(...redundancies.warnings);
issues.info.push(...redundancies.info);
// 3. Check for logical contradictions
const contradictions = checkContradictions(rules);
issues.errors.push(...contradictions.errors);
issues.warnings.push(...contradictions.warnings);
// 4. Check for edge cases that might cause issues
const edgeCases = checkEdgeCases(rules);
issues.info.push(...edgeCases);
// 5. Provide helpful suggestions
const suggestions = provideSuggestions(rules);
issues.info.push(...suggestions);
logicValidation = issues;
}
// Check for impossible filter combinations
function checkImpossibleFilters(filters, rules) {
const errors = [];
const warnings = [];
function analyze(filterNode, depth = 0) {
if (!filterNode || !filterNode.filters) return;
// Group filters by field to check for contradictions
const fieldFilters = {};
for (const f of filterNode.filters) {
if (f.field) {
if (!fieldFilters[f.field]) fieldFilters[f.field] = [];
fieldFilters[f.field].push(f);
} else if (f.filters) {
analyze(f, depth + 1);
}
}
// Check for contradictions in AND groups
if (filterNode.operator === 'AND') {
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
// Check for direct contradictions: field=X AND field=Y
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
const neFilters = fieldFiltersList.filter(f => f.operator === 'ne');
for (const eq1 of eqFilters) {
for (const eq2 of eqFilters) {
if (eq1 !== eq2 && eq1.value !== eq2.value) {
errors.push(`Impossible filter: ${field} cannot be both "${eq1.value}" AND "${eq2.value}"`);
}
}
for (const ne of neFilters) {
if (eq1.value === ne.value) {
errors.push(`Impossible filter: ${field} cannot be "${eq1.value}" AND not "${ne.value}" at the same time`);
}
}
}
// Check for in/nin contradictions
const inFilters = fieldFiltersList.filter(f => f.operator === 'in');
const ninFilters = fieldFiltersList.filter(f => f.operator === 'nin');
for (const inF of inFilters) {
if (Array.isArray(inF.value)) {
for (const ninF of ninFilters) {
if (Array.isArray(ninF.value)) {
const overlap = inF.value.filter(v => ninF.value.includes(v));
if (overlap.length > 0 && overlap.length === inF.value.length) {
errors.push(`Impossible filter: ${field} must be in ${inF.value.join(', ')} AND not in ${overlap.join(', ')}`);
} else if (overlap.length > 0) {
warnings.push(`Suspicious filter: ${field} filter has overlapping values: ${overlap.join(', ')}`);
}
}
}
}
}
}
}
// Check for redundant OR groups (field=X OR field=X)
if (filterNode.operator === 'OR') {
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
for (let i = 0; i < eqFilters.length; i++) {
for (let j = i + 1; j < eqFilters.length; j++) {
if (eqFilters[i].value === eqFilters[j].value) {
warnings.push(`Redundant filter: ${field}="${eqFilters[i].value}" appears multiple times in OR group`);
}
}
}
}
}
}
analyze(filters);
return { errors, warnings };
}
// Check for redundancies in the definition
function checkRedundancies(rules) {
const warnings = [];
const info = [];
// Check if satellite_only is redundant when filters already check for satellite
if (rules.satellite_only && rules.filters) {
const satFilter = findSatelliteFilter(rules.filters);
if (satFilter && satFilter.operator === 'eq' && satFilter.value === true) {
info.push('satellite_only=true is set, but filters already check for satellite QSOs. The filter is redundant.');
}
}
// Check if allowed_bands makes filters redundant
if (rules.allowed_bands && rules.allowed_bands.length > 0 && rules.filters) {
const bandFilters = extractBandFilters(rules.filters);
for (const bf of bandFilters) {
if (bf.operator === 'in' && Array.isArray(bf.value)) {
const allCovered = bf.value.every(b => rules.allowed_bands.includes(b));
if (allCovered) {
info.push(`allowed_bands already includes all bands in the filter. Consider removing the filter.`);
}
}
}
}
// Check if displayField matches the default for the entity type
if (rules.entityType) {
const defaults = {
'dxcc': 'entity',
'state': 'state',
'grid': 'grid',
'callsign': 'callsign'
};
const defaultField = defaults[rules.entityType];
if (rules.displayField) {
if (defaultField === rules.displayField) {
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
}
} else if (defaultField) {
info.push(`displayField will default to "${defaultField}" for entityType="${rules.entityType}".`);
}
}
return { warnings, info };
}
// Check for logical contradictions
function checkContradictions(rules) {
const errors = [];
const warnings = [];
// Check satellite_only with HF-only allowed_bands
if (rules.satellite_only && rules.allowed_bands) {
const hfBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
const hasHfOnly = rules.allowed_bands.length > 0 &&
rules.allowed_bands.every(b => hfBands.includes(b));
if (hasHfOnly) {
warnings.push('satellite_only is set but allowed_bands only includes HF bands. Satellite work typically uses VHF/UHF bands.');
}
}
// For DOK rules, verify confirmation type
if (rules.type === 'dok' && rules.confirmationType && rules.confirmationType !== 'dcl') {
warnings.push('DOK awards typically require DCL confirmation (confirmationType="dcl").');
}
// Check for impossible targets
if (rules.target) {
if (rules.type === 'entity' && rules.entityType === 'dxcc' && rules.target > 340) {
warnings.push(`Target (${rules.target}) exceeds the total number of DXCC entities (~340).`);
}
if (rules.type === 'dok' && rules.target > 700) {
info.push(`Target (${rules.target}) is high. There are ~700 DOKs in Germany.`);
}
}
return { errors, warnings };
}
// Check for edge cases
function checkEdgeCases(rules) {
const info = [];
if (rules.filters) {
const filterCount = countFilters(rules.filters);
if (filterCount > 10) {
info.push(`Complex filter structure (${filterCount} filters). Consider simplifying for better performance.`);
}
}
if (rules.modeGroups) {
const totalModes = Object.values(rules.modeGroups).reduce((sum, modes) => sum + (modes?.length || 0), 0);
if (totalModes > 20) {
info.push('Many mode groups defined. Make sure users understand the grouping logic.');
}
}
if (rules.type === 'points' && rules.stations) {
const totalPossiblePoints = rules.stations.reduce((sum, s) => sum + (s.points || 0), 0);
if (totalPossiblePoints < rules.target) {
info.push(`Even with all stations confirmed, max points (${totalPossiblePoints}) is less than target (${rules.target}). Award is impossible to complete.`);
}
}
return info;
}
// Provide helpful suggestions
function provideSuggestions(rules) {
const info = [];
// Suggest common award patterns
if (rules.type === 'entity' && rules.entityType === 'dxcc' && !rules.allowed_bands) {
info.push('Consider adding allowed_bands to restrict to specific bands (e.g., HF only: ["160m", "80m", ...]).');
}
if (rules.type === 'entity' && !rules.modeGroups && ['dxcc', 'dld'].includes(rules.entityType)) {
info.push('Consider adding modeGroups to help users filter by mode type (e.g., "Digi-Modes", "Phone-Modes").');
}
if (rules.type === 'dok' && !rules.filters) {
info.push('DOK awards can have band/mode filters via the filters property. Consider adding them for specific variations.');
}
return info;
}
// Helper: Find satellite-related filter
function findSatelliteFilter(filters, depth = 0) {
if (!filters || depth > 5) return null;
if (filters.field === 'satellite' || filters.field === 'satName') {
return filters;
}
if (filters.filters) {
for (const f of filters.filters) {
const found = findSatelliteFilter(f, depth + 1);
if (found) return found;
}
}
return null;
}
// Helper: Extract band filters
function extractBandFilters(filters, depth = 0) {
if (!filters || depth > 5) return [];
const result = [];
if (filters.field === 'band') {
result.push(filters);
}
if (filters.filters) {
for (const f of filters.filters) {
result.push(...extractBandFilters(f, depth + 1));
}
}
return result;
}
// Helper: Count total filters
function countFilters(filters, depth = 0) {
if (!filters || depth > 5) return 0;
let count = 0;
if (filters.filters) {
for (const f of filters.filters) {
if (f.filters) {
count += 1 + countFilters(f, depth + 1);
} else {
count += 1;
}
}
}
return count;
}
function getSeverityClass(type) {
switch (type) {
case 'error': return 'severity-error';
case 'warning': return 'severity-warning';
case 'info': return 'severity-info';
default: return '';
}
}
</script>
{#if logicValidation || testResult || testError}
<div class="modal-overlay" on:click={onClose}>
<div class="modal-content large" on:click|stopPropagation>
<div class="modal-header">
<h2>{testResult ? 'Test Results' : 'Award Validation'}{awardId ? `: ${awardId}` : ''}</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
<!-- Logic Validation Section -->
{#if logicValidation && (logicValidation.errors.length > 0 || logicValidation.warnings.length > 0 || logicValidation.info.length > 0)}
<div class="validation-section">
<h3>Logic Validation</h3>
{#if logicValidation.errors.length > 0}
<div class="validation-block errors">
<h4>Errors (must fix)</h4>
<ul>
{#each logicValidation.errors as err}
<li class="severity-error">{err}</li>
{/each}
</ul>
</div>
{/if}
{#if logicValidation.warnings.length > 0}
<div class="validation-block warnings">
<h4>Warnings</h4>
<ul>
{#each logicValidation.warnings as warn}
<li class="severity-warning">{warn}</li>
{/each}
</ul>
</div>
{/if}
{#if logicValidation.info.length > 0}
<div class="validation-block info">
<h4>Suggestions</h4>
<ul>
{#each logicValidation.info as info}
<li class="severity-info">{info}</li>
{/each}
</ul>
</div>
{/if}
{#if logicValidation.errors.length === 0 && logicValidation.warnings.length === 0}
<div class="validation-block success">
<p>No issues found. The award definition looks good!</p>
</div>
{/if}
</div>
{/if}
<!-- Test Configuration -->
<div class="test-config">
<h3>Test Calculation</h3>
<p class="help-text">Select a user to test the award calculation with their QSO data.</p>
<div class="user-selector">
<label for="test-user">Test with user:</label>
<select id="test-user" bind:value={selectedUserId}>
<option value="">-- Select a user --</option>
{#each users as user}
<option value={user.id}>{user.callsign} ({user.email}) - {user.qsoCount || 0} QSOs</option>
{/each}
</select>
</div>
<button
class="btn btn-primary"
on:click={runTest}
disabled={loading || !selectedUserId || !awardId}
>
{loading ? 'Testing...' : 'Run Test'}
</button>
</div>
<!-- Test Results -->
{#if testError}
<div class="test-results error">
<h4>Test Failed</h4>
<p>{testError}</p>
</div>
{:else if testResult}
<div class="test-results success">
<h4>Test Results</h4>
<div class="result-summary">
<div class="result-item">
<span class="label">Award:</span>
<span class="value">{testResult.award?.name || awardId}</span>
</div>
<div class="result-item">
<span class="label">Worked:</span>
<span class="value">{testResult.worked || 0}</span>
</div>
<div class="result-item">
<span class="label">Confirmed:</span>
<span class="value confirmed">{testResult.confirmed || 0}</span>
</div>
<div class="result-item">
<span class="label">Target:</span>
<span class="value">{testResult.target || 0}</span>
</div>
<div class="result-item">
<span class="label">Progress:</span>
<span class="value progress">{testResult.percentage || 0}%</span>
</div>
</div>
{#if testResult.warnings && testResult.warnings.length > 0}
<div class="result-warnings">
<h5>Warnings:</h5>
<ul>
{#each testResult.warnings as warning}
<li>{warning}</li>
{/each}
</ul>
</div>
{/if}
{#if testResult.sampleEntities && testResult.sampleEntities.length > 0}
<div class="sample-entities">
<h5>Sample Matched Entities (first {testResult.sampleEntities.length}):</h5>
<div class="entities-list">
{#each testResult.sampleEntities as entity}
<span class="entity-tag">{entity}</span>
{/each}
</div>
</div>
{:else}
<div class="no-matches">
<p>No entities matched. Check filters and band/mode restrictions.</p>
</div>
{/if}
</div>
{/if}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" on:click={onClose}>Close</button>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--bg-card);
border-radius: var(--border-radius-lg);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-md);
}
.modal-content.large {
max-width: 900px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
margin: 0;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.validation-section {
margin-bottom: 2rem;
}
.validation-section h3 {
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.validation-block {
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
}
.validation-block h4 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: var(--text-primary);
}
.validation-block.errors {
background-color: var(--color-error-bg);
border-left: 4px solid var(--color-error);
}
.validation-block.warnings {
background-color: var(--color-warning-bg);
border-left: 4px solid var(--color-warning);
}
.validation-block.info {
background-color: var(--color-info-bg);
border-left: 4px solid var(--color-info);
}
.validation-block.success {
background-color: var(--color-success-bg);
border-left: 4px solid var(--color-success);
}
.validation-block ul {
margin: 0;
padding-left: 1.5rem;
}
.validation-block li {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.severity-error {
color: var(--color-error);
}
.severity-warning {
color: var(--color-warning);
}
.severity-info {
color: var(--color-info);
}
.test-config {
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
}
.test-config h3 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.help-text {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.user-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.user-selector label {
font-weight: 500;
color: var(--text-primary);
}
.user-selector select {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-input);
color: var(--text-primary);
}
.test-results {
margin-top: 1.5rem;
padding: 1rem;
border-radius: var(--border-radius);
}
.test-results.error {
background-color: var(--color-error-bg);
border-left: 4px solid var(--color-error);
}
.test-results.success {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.test-results h4 {
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.result-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.result-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background: var(--bg-card);
border-radius: var(--border-radius);
}
.result-item .label {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.result-item .value {
font-size: 1.25rem;
font-weight: bold;
color: var(--text-primary);
}
.result-item .value.confirmed {
color: var(--color-success);
}
.result-item .value.progress {
color: var(--color-primary);
}
.result-warnings {
background-color: var(--color-warning-bg);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
}
.result-warnings h5 {
margin: 0 0 0.5rem 0;
color: var(--color-warning);
}
.result-warnings ul {
margin: 0;
padding-left: 1.5rem;
}
.sample-entities {
background-color: var(--color-info-bg);
padding: 0.75rem;
border-radius: var(--border-radius);
}
.sample-entities h5 {
margin: 0 0 0.5rem 0;
color: var(--color-info);
}
.entities-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.entity-tag {
background-color: var(--bg-card);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
color: var(--color-info);
}
.no-matches {
background-color: var(--color-warning-bg);
padding: 0.75rem;
border-radius: var(--border-radius);
text-align: center;
color: var(--color-warning);
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
.btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
color: var(--text-inverted);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-primary-hover);
}
.btn-secondary {
background-color: #6c757d;
}
.btn-secondary:hover {
background-color: #5a6268;
}
@media (max-width: 600px) {
.modal-content {
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
.user-selector {
flex-direction: column;
align-items: stretch;
}
.result-summary {
grid-template-columns: 1fr 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -173,20 +173,20 @@
<style> <style>
.container { .container {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
} }
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
color: #333; color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.subtitle { .subtitle {
font-size: 1.25rem; font-size: 1.25rem;
color: #666; color: var(--text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -205,28 +205,29 @@
.filter-group label { .filter-group label {
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 0.9rem; font-size: 0.9rem;
} }
.filter-group select { .filter-group select {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid #ccc; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
background-color: white; background-color: var(--bg-input);
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
min-width: 150px; min-width: 150px;
color: var(--text-primary);
} }
.filter-group select:hover { .filter-group select:hover {
border-color: #4a90e2; border-color: var(--color-primary);
} }
.filter-group select:focus { .filter-group select:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); box-shadow: var(--focus-ring);
} }
.loading, .loading,
@@ -235,11 +236,11 @@
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
color: #d32f2f; color: var(--color-error);
} }
.awards-grid { .awards-grid {
@@ -249,11 +250,11 @@
} }
.award-card { .award-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -268,16 +269,16 @@
.award-header h2 { .award-header h2 {
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
.category { .category {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--border-radius-pill);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -285,7 +286,7 @@
} }
.description { .description {
color: #666; color: var(--text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
flex-grow: 1; flex-grow: 1;
} }
@@ -295,8 +296,8 @@
gap: 2rem; gap: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
background-color: #f8f9fa; background-color: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-item { .info-item {
@@ -307,14 +308,14 @@
.label { .label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
} }
.value { .value {
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -325,15 +326,15 @@
.progress-bar { .progress-bar {
width: 100%; width: 100%;
height: 8px; height: 8px;
background-color: #e0e0e0; background-color: var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #4a90e2 0%, #357abd 100%); background: var(--gradient-primary);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -348,7 +349,7 @@
text-align: center; text-align: center;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
.worked, .worked,
@@ -357,20 +358,20 @@
} }
.worked { .worked {
color: #666; color: var(--text-secondary);
} }
.confirmed { .confirmed {
color: #4a90e2; color: var(--color-primary);
} }
.btn { .btn {
display: inline-block; display: inline-block;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -379,6 +380,6 @@
} }
.btn:hover { .btn:hover {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -877,7 +877,7 @@
<style> <style>
.container { .container {
max-width: 1200px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
@@ -899,22 +899,22 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.header-buttons { .header-buttons {
@@ -924,16 +924,16 @@
} }
.filters { .filters {
background: #f8f9fa; background: var(--bg-secondary);
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.filters h3 { .filters h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
} }
.filters-header { .filters-header {
@@ -947,11 +947,11 @@
.filter-count { .filter-count {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--text-secondary);
} }
.filter-count strong { .filter-count strong {
color: #4a90e2; color: var(--color-primary);
font-weight: 600; font-weight: 600;
} }
@@ -964,24 +964,28 @@
.filter-row select { .filter-row select {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input { .search-input {
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg-input);
color: var(--text-primary);
} }
.search-input:focus { .search-input:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); box-shadow: var(--focus-ring);
} }
.checkbox-label { .checkbox-label {
@@ -994,7 +998,7 @@
.btn { .btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -1002,12 +1006,12 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-primary:disabled { .btn-primary:disabled {
@@ -1016,21 +1020,21 @@
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.btn-danger { .btn-danger {
background-color: #dc3545; background-color: var(--color-error);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background-color: #c82333; background-color: var(--color-error-hover);
} }
.btn-danger:disabled { .btn-danger:disabled {
@@ -1043,14 +1047,14 @@
font-size: 0.875rem; font-size: 0.875rem;
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px; border-radius: var(--border-radius);
cursor: pointer; cursor: pointer;
color: inherit; color: inherit;
} }
.alert { .alert {
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1058,21 +1062,21 @@
} }
.alert-success { .alert-success {
background-color: #d4edda; background-color: var(--color-success-bg);
border: 1px solid #c3e6cb; border: 1px solid var(--color-success);
color: #155724; color: var(--color-success);
} }
.alert-error { .alert-error {
background-color: #f8d7da; background-color: var(--color-error-bg);
border: 1px solid #f5c6cb; border: 1px solid var(--color-error);
color: #721c24; color: var(--color-error);
} }
.alert-info { .alert-info {
background-color: #d1ecf1; background-color: var(--color-info-bg);
border: 1px solid #bee5eb; border: 1px solid var(--color-info);
color: #0c5460; color: var(--color-info);
} }
.alert h3 { .alert h3 {
@@ -1090,11 +1094,13 @@
.delete-input { .delete-input {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
width: 200px; width: 200px;
background: var(--bg-input);
color: var(--text-primary);
} }
.delete-buttons { .delete-buttons {
@@ -1105,8 +1111,8 @@
.qso-table-container { .qso-table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
} }
.qso-table { .qso-table {
@@ -1118,39 +1124,39 @@
.qso-table td { .qso-table td {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.qso-table th { .qso-table th {
background-color: #f8f9fa; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
} }
.qso-table tr:hover { .qso-table tr:hover {
background-color: #f8f9fa; background-color: var(--bg-secondary);
} }
.callsign { .callsign {
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
.badge { .badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
.badge-success { .badge-success {
background-color: #d4edda; background-color: var(--color-success-bg);
color: #155724; color: var(--color-success);
} }
.badge-pending { .badge-pending {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
color: #856404; color: var(--badge-pending-text);
} }
.confirmation-list { .confirmation-list {
@@ -1168,29 +1174,29 @@
.service-type { .service-type {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.confirmation-date { .confirmation-date {
font-size: 0.875rem; font-size: 0.875rem;
color: #333; color: var(--text-primary);
} }
.loading, .error, .empty { .loading, .error, .empty {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #666; color: var(--text-secondary);
} }
.error { .error {
color: #dc3545; color: var(--color-error);
} }
.showing { .showing {
text-align: center; text-align: center;
color: #666; color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
margin-top: 1rem; margin-top: 1rem;
} }
@@ -1201,14 +1207,14 @@
align-items: center; align-items: center;
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 1rem; padding: 1rem;
background: #f8f9fa; background: var(--bg-secondary);
border-radius: 8px; border-radius: var(--border-radius-lg);
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
} }
.pagination-info { .pagination-info {
color: #666; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -1226,7 +1232,7 @@
.page-ellipsis { .page-ellipsis {
padding: 0 0.5rem; padding: 0 0.5rem;
color: #666; color: var(--text-secondary);
} }
.btn-small { .btn-small {
@@ -1241,16 +1247,16 @@
} }
.import-log { .import-log {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
} }
.import-log h3 { .import-log h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
font-size: 1.25rem; font-size: 1.25rem;
} }
@@ -1264,15 +1270,15 @@
.log-section h4 { .log-section h4 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
color: #555; color: var(--text-secondary);
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
} }
.log-table-container { .log-table-container {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
} }
.log-table { .log-table {
@@ -1285,13 +1291,13 @@
.log-table td { .log-table td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.log-table th { .log-table th {
background-color: #f8f9fa; background-color: var(--bg-secondary);
font-weight: 600; font-weight: 600;
color: #333; color: var(--text-primary);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -1300,12 +1306,12 @@
} }
.log-table tr:hover { .log-table tr:hover {
background-color: #f8f9fa; background-color: var(--bg-secondary);
} }
.log-table .callsign { .log-table .callsign {
font-weight: 600; font-weight: 600;
color: #4a90e2; color: var(--color-primary);
} }
/* QSO Detail Modal Styles */ /* QSO Detail Modal Styles */
@@ -1315,11 +1321,11 @@
} }
.qso-row:hover { .qso-row:hover {
background-color: #f0f7ff !important; background-color: var(--color-primary-light) !important;
} }
.qso-row:focus { .qso-row:focus {
outline: 2px solid #4a90e2; outline: 2px solid var(--color-primary);
outline-offset: -2px; outline-offset: -2px;
} }
@@ -1340,13 +1346,13 @@
/* Modal Content */ /* Modal Content */
.modal-content { .modal-content {
background: white; background: var(--bg-card);
border-radius: 8px; border-radius: var(--border-radius-lg);
max-width: 700px; max-width: 700px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-md);
} }
/* Modal Header */ /* Modal Header */
@@ -1355,13 +1361,13 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1.5rem; padding: 1.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.modal-header h2 { .modal-header h2 {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
color: #333; color: var(--text-primary);
} }
.modal-close { .modal-close {
@@ -1376,14 +1382,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #666; color: var(--text-secondary);
border-radius: 4px; border-radius: var(--border-radius);
transition: all 0.2s; transition: all 0.2s;
} }
.modal-close:hover { .modal-close:hover {
background-color: #f0f0f0; background-color: var(--bg-tertiary);
color: #333; color: var(--text-primary);
} }
/* Modal Body */ /* Modal Body */
@@ -1402,10 +1408,10 @@
.qso-detail-section h3 { .qso-detail-section h3 {
font-size: 1.1rem; font-size: 1.1rem;
color: #4a90e2; color: var(--color-primary);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0; border-bottom: 2px solid var(--border-color);
} }
/* Detail Grid */ /* Detail Grid */
@@ -1423,14 +1429,14 @@
.detail-label { .detail-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
} }
.detail-value { .detail-value {
font-size: 0.95rem; font-size: 0.95rem;
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -1443,10 +1449,10 @@
.confirmation-service h4 { .confirmation-service h4 {
font-size: 1rem; font-size: 1rem;
color: #333; color: var(--text-primary);
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border-color);
} }
.confirmation-status-item { .confirmation-status-item {
@@ -1466,19 +1472,19 @@
} }
.status-badge.confirmed { .status-badge.confirmed {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: white;
} }
.status-badge.not-confirmed, .status-badge.not-confirmed,
.status-badge.no-data { .status-badge.no-data {
background-color: #e0e0e0; background-color: var(--border-color);
color: #666; color: var(--text-secondary);
} }
.status-badge.unknown { .status-badge.unknown {
background-color: #fff3cd; background-color: var(--badge-pending-bg);
color: #856404; color: var(--badge-pending-text);
} }
/* Meta Info */ /* Meta Info */
@@ -1486,18 +1492,18 @@
display: flex; display: flex;
gap: 2rem; gap: 2rem;
padding: 1rem; padding: 1rem;
background-color: #f8f9fa; background-color: var(--bg-secondary);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.8rem; font-size: 0.8rem;
} }
.meta-label { .meta-label {
color: #666; color: var(--text-secondary);
font-weight: 600; font-weight: 600;
} }
.meta-value { .meta-value {
color: #333; color: var(--text-primary);
font-family: monospace; font-family: monospace;
} }
@@ -1507,15 +1513,15 @@
} }
.modal-content::-webkit-scrollbar-track { .modal-content::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--bg-secondary);
} }
.modal-content::-webkit-scrollbar-thumb { .modal-content::-webkit-scrollbar-thumb {
background: #888; background: var(--text-muted);
border-radius: 4px; border-radius: 4px;
} }
.modal-content::-webkit-scrollbar-thumb:hover { .modal-content::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--text-secondary);
} }
</style> </style>

View File

@@ -32,9 +32,9 @@
} }
.stat-card { .stat-card {
background: white; background: var(--bg-card);
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
padding: 1.5rem; padding: 1.5rem;
text-align: center; text-align: center;
} }
@@ -42,11 +42,11 @@
.stat-value { .stat-value {
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
color: #4a90e2; color: var(--color-primary);
} }
.stat-label { .stat-label {
color: #666; color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
</style> </style>

View File

@@ -15,6 +15,7 @@
disabled={isRunning || deleting} disabled={isRunning || deleting}
> >
{#if isRunning} {#if isRunning}
<span class="spinner"></span>
{label} Syncing... {label} Syncing...
{:else} {:else}
Sync from {label} Sync from {label}
@@ -23,11 +24,11 @@
<style> <style>
.lotw-btn { .lotw-btn {
background-color: #4a90e2; background-color: var(--color-primary);
} }
.lotw-btn:hover:not(:disabled) { .lotw-btn:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.dcl-btn { .dcl-btn {
@@ -37,4 +38,22 @@
.dcl-btn:hover:not(:disabled) { .dcl-btn:hover:not(:disabled) {
background-color: #d35400; background-color: #d35400;
} }
/* Spinner animation */
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.5rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { authAPI } from '$lib/api.js'; import { authAPI, autoSyncAPI } from '$lib/api.js';
import { auth } from '$lib/stores.js'; import { auth } from '$lib/stores.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -17,9 +17,23 @@
let hasLoTWCredentials = false; let hasLoTWCredentials = false;
let hasDCLCredentials = false; let hasDCLCredentials = false;
// Auto-sync settings
let autoSyncSettings = {
lotwEnabled: false,
lotwIntervalHours: 24,
lotwNextSyncAt: null,
dclEnabled: false,
dclIntervalHours: 24,
dclNextSyncAt: null,
};
let loadingAutoSync = false;
let savingAutoSync = false;
let successAutoSync = false;
onMount(async () => { onMount(async () => {
// Load user profile to check if credentials exist // Load user profile to check if credentials exist
await loadProfile(); await loadProfile();
await loadAutoSyncSettings();
}); });
async function loadProfile() { async function loadProfile() {
@@ -41,6 +55,21 @@
} }
} }
async function loadAutoSyncSettings() {
try {
loadingAutoSync = true;
const response = await autoSyncAPI.getSettings();
if (response.settings) {
autoSyncSettings = response.settings;
}
} catch (err) {
console.error('Failed to load auto-sync settings:', err);
// Don't show error for auto-sync, it's optional
} finally {
loadingAutoSync = false;
}
}
async function handleSaveLoTW(e) { async function handleSaveLoTW(e) {
e.preventDefault(); e.preventDefault();
@@ -92,6 +121,40 @@
} }
} }
async function handleSaveAutoSync(e) {
e.preventDefault();
try {
savingAutoSync = true;
error = null;
successAutoSync = false;
await autoSyncAPI.updateSettings({
lotwEnabled: autoSyncSettings.lotwEnabled,
lotwIntervalHours: parseInt(autoSyncSettings.lotwIntervalHours),
dclEnabled: autoSyncSettings.dclEnabled,
dclIntervalHours: parseInt(autoSyncSettings.dclIntervalHours),
});
console.log('Auto-sync settings saved successfully!');
// Reload settings to get updated next sync times
await loadAutoSyncSettings();
successAutoSync = true;
} catch (err) {
console.error('Auto-sync save failed:', err);
error = err.message;
} finally {
savingAutoSync = false;
}
}
function formatNextSyncTime(dateString) {
if (!dateString) return 'Not scheduled';
const date = new Date(dateString);
return date.toLocaleString();
}
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
// Use hard redirect to ensure proper navigation after logout // Use hard redirect to ensure proper navigation after logout
@@ -241,6 +304,116 @@
</p> </p>
</div> </div>
</div> </div>
<div class="settings-section">
<h2>Automatic Sync Settings</h2>
<p class="help-text">
Configure automatic synchronization for LoTW and DCL. The server will automatically
sync your QSOs at the specified interval. Credentials must be configured above.
</p>
{#if !hasLoTWCredentials && !hasDCLCredentials}
<div class="alert alert-info">
<strong>Note:</strong> Configure LoTW or DCL credentials above to enable automatic sync.
</div>
{/if}
<form on:submit={handleSaveAutoSync} class="settings-form">
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if successAutoSync}
<div class="alert alert-success">
Auto-sync settings saved successfully!
</div>
{/if}
<h3>LoTW Auto-Sync</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
bind:checked={autoSyncSettings.lotwEnabled}
disabled={!hasLoTWCredentials || savingAutoSync}
/>
Enable LoTW auto-sync
</label>
{#if !hasLoTWCredentials}
<p class="hint">Configure LoTW credentials above first</p>
{/if}
</div>
<div class="form-group">
<label for="lotwIntervalHours">Sync interval (hours)</label>
<input
id="lotwIntervalHours"
type="number"
min="1"
max="720"
bind:value={autoSyncSettings.lotwIntervalHours}
disabled={!autoSyncSettings.lotwEnabled || savingAutoSync}
/>
<p class="hint">
Minimum 1 hour, maximum 720 hours (30 days). Default: 24 hours.
</p>
</div>
</div>
{#if autoSyncSettings.lotwEnabled && autoSyncSettings.lotwNextSyncAt}
<p class="next-sync-info">
Next scheduled sync: <strong>{formatNextSyncTime(autoSyncSettings.lotwNextSyncAt)}</strong>
</p>
{/if}
<hr class="divider" />
<h3>DCL Auto-Sync</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
bind:checked={autoSyncSettings.dclEnabled}
disabled={!hasDCLCredentials || savingAutoSync}
/>
Enable DCL auto-sync
</label>
{#if !hasDCLCredentials}
<p class="hint">Configure DCL credentials above first</p>
{/if}
</div>
<div class="form-group">
<label for="dclIntervalHours">Sync interval (hours)</label>
<input
id="dclIntervalHours"
type="number"
min="1"
max="720"
bind:value={autoSyncSettings.dclIntervalHours}
disabled={!autoSyncSettings.dclEnabled || savingAutoSync}
/>
<p class="hint">
Minimum 1 hour, maximum 720 hours (30 days). Default: 24 hours.
</p>
</div>
</div>
{#if autoSyncSettings.dclEnabled && autoSyncSettings.dclNextSyncAt}
<p class="next-sync-info">
Next scheduled sync: <strong>{formatNextSyncTime(autoSyncSettings.dclNextSyncAt)}</strong>
</p>
{/if}
<button type="submit" class="btn btn-primary" disabled={savingAutoSync}>
{savingAutoSync ? 'Saving...' : 'Save Auto-Sync Settings'}
</button>
</form>
</div>
</div> </div>
<style> <style>
@@ -267,40 +440,46 @@
.header h1 { .header h1 {
margin: 0; margin: 0;
color: #333; color: var(--text-primary);
} }
.back-button { .back-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: var(--text-inverted);
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.back-button:hover { .back-button:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.user-info { .user-info {
background: #f8f9fa; background: var(--bg-secondary);
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.user-info h2 { .user-info h2 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #333; color: var(--text-primary);
} }
.user-info p { .user-info p {
margin: 0.5rem 0; margin: 0.5rem 0;
color: #666; color: var(--text-secondary);
}
.user-info :global(strong),
.settings-form :global(strong),
.next-sync-info :global(strong) {
color: var(--text-primary);
} }
.settings-section { .settings-section {
@@ -310,44 +489,44 @@
.settings-section h2 { .settings-section h2 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.25rem; font-size: 1.25rem;
color: #333; color: var(--text-primary);
} }
.help-text { .help-text {
color: #666; color: var(--text-secondary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
line-height: 1.6; line-height: 1.6;
} }
.alert { .alert {
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: var(--border-radius);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.alert-info { .alert-info {
background-color: #d1ecf1; background-color: var(--color-info-bg);
border: 1px solid #bee5eb; border: 1px solid var(--color-info);
color: #0c5460; color: var(--color-info-text);
} }
.alert-error { .alert-error {
background-color: #f8d7da; background-color: var(--color-error-bg);
border: 1px solid #f5c6cb; border: 1px solid var(--color-error);
color: #721c24; color: var(--color-error-text);
} }
.alert-success { .alert-success {
background-color: #d4edda; background-color: var(--color-success-bg);
border: 1px solid #c3e6cb; border: 1px solid var(--color-success);
color: #155724; color: var(--color-success);
} }
.settings-form { .settings-form {
background: white; background: var(--bg-card);
padding: 2rem; padding: 2rem;
border: 1px solid #e0e0e0; border: 1px solid var(--border-color);
border-radius: 8px; border-radius: var(--border-radius-lg);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -359,34 +538,36 @@
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;
color: #333; color: var(--text-primary);
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--border-color);
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
box-sizing: border-box; box-sizing: border-box;
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: #4a90e2; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1); box-shadow: var(--focus-ring);
} }
.hint { .hint {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.btn { .btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--border-radius);
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
@@ -394,12 +575,12 @@
} }
.btn-primary { .btn-primary {
background-color: #4a90e2; background-color: var(--color-primary);
color: white; color: var(--text-inverted);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #357abd; background-color: var(--color-primary-hover);
} }
.btn-primary:disabled { .btn-primary:disabled {
@@ -408,38 +589,93 @@
} }
.btn-secondary { .btn-secondary {
background-color: #6c757d; background-color: var(--color-secondary);
color: white; color: var(--text-inverted);
} }
.btn-secondary:hover { .btn-secondary:hover {
background-color: #5a6268; background-color: var(--color-secondary-hover);
} }
.info-box { .info-box {
background: #e8f4fd; background: var(--color-info-bg);
border-left: 4px solid #4a90e2; border-left: 4px solid var(--color-primary);
padding: 1.5rem; padding: 1.5rem;
border-radius: 4px; border-radius: var(--border-radius);
} }
.info-box h3 { .info-box h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: #333; color: var(--text-primary);
} }
.info-box p { .info-box p {
margin: 0.5rem 0; margin: 0.5rem 0;
color: #666; color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
} }
.info-box a { .info-box a {
color: #4a90e2; color: var(--text-link);
text-decoration: none; text-decoration: none;
} }
.info-box a:hover { .info-box a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Auto-sync specific styles */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
.checkbox-group {
padding-top: 0.75rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
color: var(--text-primary);
}
.checkbox-group input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 2rem 0;
}
.next-sync-info {
padding: 0.75rem 1rem;
background-color: var(--color-info-bg);
border-left: 4px solid var(--color-primary);
border-radius: var(--border-radius);
margin-top: 1rem;
font-size: 0.9rem;
color: var(--text-primary);
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
gap: 1rem;
}
}
</style> </style>

View File

@@ -2,4 +2,5 @@
# Production start script # Production start script
# Run backend server (Elysia errors are harmless warnings that don't affect functionality) # Run backend server (Elysia errors are harmless warnings that don't affect functionality)
export LOG_LEVEL=debug
exec bun src/backend/index.js exec bun src/backend/index.js