Compare commits

..

57 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
cce520a00e chore: code cleanup - remove duplicates and add caching
- Delete duplicate getCacheStats() function in cache.service.js
- Fix date calculation bug in lotw.service.js (was Date.now()-Date.now())
- Extract duplicate helper functions (yieldToEventLoop, getQSOKey) to sync-helpers.js
- Cache award definitions in memory to avoid repeated file I/O
- Delete unused parseDCLJSONResponse() function
- Remove unused imports (getPerformanceSummary, resetPerformanceMetrics)
- Auto-discover award JSON files instead of hardcoded list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 10:22:00 +01:00
d9e0e462b9 docs: update CLAUDE.md with recent changes
Rewrote documentation to reflect current state of the application:

- Updated award list (DXCC, DXCC SAT, DLD, WAS, VUCC SAT, SAT-RS44, 73 on 73)
- Added allowed_bands and satellite_only rule options
- Added DXCC SAT award documentation
- Added QSO deletion endpoint
- Updated award detail view features (unique entity progress, satellite grouping, band sorting)
- Removed outdated DLD variant examples
- Streamlined and reorganized sections

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:47:11 +01:00
ebdd75e03f fix: invalidate caches after deleting QSOs
After deleting all QSOs, invalidate the stats and user caches so the
QSO page shows updated statistics instead of stale cached data.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:28:37 +01:00
205b311244 fix: handle foreign key constraints when deleting QSOs
The qso_changes table has a foreign key reference to qsos.id, which
was preventing QSO deletion. Now deletes related qso_changes records
first before deleting QSOs.

Also added better error logging to the DELETE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:26:43 +01:00
6bc0a2f9b2 fix: return correct count from deleteQSOs function
The db.delete() returns a result object with a 'changes' property
indicating the number of affected rows, not the count directly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:22:13 +01:00
8550b91255 feat: add DXCC SAT award for satellite-only QSOs
Added new award "DXCC SAT" that only counts satellite QSOs (QSOs with
satName field set). This adds a new "satellite_only" key to award
definitions that filters to only include satellite communications.

Award definition:
- ID: dxcc-sat
- Name: DXCC SAT
- Target: 100 DXCC entities
- Only satellite QSOs count

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:25:13 +01:00
a93d4ff85b refactor: remove DLD 80m CW award variant
Removed dld-80m-cw.json award definition. Only the main DLD award
remains, which covers all bands and modes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:58 +01:00
f3ee1be651 refactor: remove DLD variant awards (80m, 40m, CW)
Removed the following DLD award variants:
- dld-80m.json
- dld-40m.json
- dld-cw.json

Kept dld-80m-cw.json as it represents a more specific combination.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:22:09 +01:00
6c9aa1efe7 feat: add allowed_bands filter to award definitions
Adds a new "allowed_bands" key to award definitions that restricts which
bands count toward an award. If absent, all bands are allowed (default
behavior).

Applied to DXCC award to only count HF bands (160m-10m), excluding
VHF/UHF bands like 6m, 2m, and 70cm.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:19:32 +01:00
14c7319c9e refactor: remove DXCC CW award and rename DXCC Mixed Mode to DXCC
- Removed dxcc-cw.json award definition
- Renamed "DXCC Mixed Mode" to "DXCC" in dxcc.json
- Changed award ID from "dxcc-mixed" to "dxcc"
- Removed dxcc-cw.json from awards service file list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:14:33 +01:00
5792a98dca feat: sort band columns by wavelength instead of alphabetically
Band columns are now sorted by wavelength (longest to shortest):
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT.

Unknown bands are sorted to the end.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:10:24 +01:00
aa25d21c6b fix: count unique entities in column sums instead of QSO counts
Column sums now correctly count unique entities (e.g., unique DXCC
countries per band) instead of counting individual entity entries or
QSOs. This matches the award progress semantics.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:07:38 +01:00
e14da11a93 fix: correct column sum calculation for satellite QSOs
The SAT column sum was always showing 0 because it was filtering by
e.band === 'SAT', but entities still have their original band in the
data. Now it correctly identifies satellite QSOs by checking if any
QSOs have satName.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:04:51 +01:00
dc34fc20b1 feat: group satellite QSOs under SAT column in award detail
Satellite QSOs are now grouped under a "SAT" column instead of their
frequency band. The backend now includes satName in QSO data, and the
frontend detects satellite QSOs and groups them appropriately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 08:03:03 +01:00
c75e55d130 feat: show unique entity progress in award summary
Summary cards now display unique entity counts (e.g., unique DXCC countries)
instead of per-band/mode slot counts. This shows actual award progress:
Total entities worked, confirmed, and needed to reach the award target.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:58:21 +01:00
89edd07722 feat: make award summary respect mode filter and remove mode from table headers
Summary cards (Total, Confirmed, Worked, Needed) now update based on the
selected mode filter. Also removed redundant mode display from table column
headers since the mode is already visible in the filter dropdown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:52:13 +01:00
dd3beef9af feat: add award detail view with QSO count per slot and mode filter
- Award detail page now shows QSO counts per (entity, band, mode) slot
- Click count to open modal with all QSOs for that slot
- Click QSO in list to view full details
- Add mode filter: "Mixed Mode" aggregates by band, specific modes show (band, mode) columns
- Backend groups by slot and collects all confirmed QSOs in qsos array
- Frontend displays clickable count links (removed blue bubbles)

Backend changes:
- calculateDOKAwardProgress(): groups by (DOK, band, mode), collects qsos array
- calculatePointsAwardProgress(): updated for all count modes with qsos array
- getAwardEntityBreakdown(): groups by (entity, band, mode) slots

Frontend changes:
- Add mode filter dropdown with "Mixed Mode" default
- Update grouping logic to handle mixed mode vs specific mode
- Replace count badges with simple clickable links
- Add QSO list modal showing all QSOs per slot
- Add Mode column to QSO list (useful in mixed mode)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 07:34:55 +01:00
695000e35c docs: add comprehensive award system specification
Add complete specification document for the JSON-driven award
calculation system. Documents all rule types, filter operators,
QSO schema, and implementation guidance suitable for porting
to any programming language.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:50:38 +01:00
bdd8aa497d fix: admin action log and impersonation improvements
- Fix admin action log not displaying entries (use raw sqlite for self-join)
- Add global impersonation banner to all pages during impersonation
- Fix timestamp display in action log (convert Unix seconds to milliseconds)
- Add loginWithToken method to auth store for direct token authentication
- Fix /api/auth/me to include impersonatedBy field from JWT
- Remove duplicate impersonation code from admin page

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:26:20 +01:00
7c209e3270 fix: correct last-sync date and logout redirect issues
- Fix admin users last-sync showing 1970 instead of actual sync date
  - Changed from MAX(qsos.createdAt) to MAX(syncJobs.completedAt)
  - Added timestamp conversion (seconds to milliseconds) for proper Date serialization
- Fix logout redirect not working from admin dashboard
  - Changed from goto() to window.location.href for hard redirect
  - Ensures proper navigation after auth state changes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 17:49:27 +01:00
6d3291e331 chore: consolidate env templates and remove Docker docs from master
- Merge .env.production.template into .env.example
- Remove Docker Deployment section from CLAUDE.md (now on docker branch)
- Update README.md to reference .env.example
- Update environment variable documentation

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:08:27 +01:00
c0a471f7c2 chore: remove Docker files from master branch
Master is now for standalone/run-on-metal deployment only.
Docker-related files moved to dedicated 'docker' branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:05:39 +01:00
ae4e60f966 chore: remove old phase documentation and development notes
Remove outdated phase markdown files and optimize.md that are no longer relevant to the active codebase.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:03:25 +01:00
dbca64a03c fix: use correct user id field for admin impersonate and role change modals
The modals were using selectedUser.userId but the user object has the field
named id, not userId. This caused undefined to be passed to the backend,
resulting in "Invalid user ID" error when trying to impersonate or change
user roles.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 13:57:11 +01:00
c56226e05b feat: add 73 on 73 satellite award
Add new award for confirming 73 unique QSO partners via AO-73 satellite.
Counts unique callsigns confirmed via LoTW with satName filter.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 13:48:44 +01:00
8f8abfc651 refactor: remove redundant role field, keep only is_admin
- Remove role column from users schema (migration 0003)
- Update auth and admin services to use is_admin only
- Remove role from JWT token payloads
- Update admin CLI to use is_admin field
- Update frontend admin page to use isAdmin boolean
- Fix security: remove console.log dumping credentials in settings

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:41:41 +01:00
fc44fef91a feat: add migration for admin actions and role fields
Adds new tables and columns for admin functionality:

- Create admin_actions table for audit logging
- Create qso_changes table for sync job rollback support
- Add role column to users (default: 'user')
- Add is_admin column to users (default: false)

No data loss - uses ALTER TABLE with safe defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 10:37:05 +01:00
7026f2bca7 perf: optimize LoTW and DCL sync with batch operations
Fixes frontend freeze during large sync operations (8000+ QSOs).

Root cause: Sequential processing with individual database operations
(~24,000 queries for 8000 QSOs) blocked the event loop, preventing
polling requests from being processed.

Changes:
- Process QSOs in batches of 100
- Single SELECT query per batch for duplicate detection
- Batch INSERTs for new QSOs and change tracking
- Add yield points (setImmediate) after each batch to allow
  event loop processing of polling requests

Performance: ~98% reduction in database operations
Before: 8000 QSOs × 3 queries = ~24,000 sequential operations
After: 80 batches × ~4 operations = ~320 operations

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 10:28:24 +01:00
e88537754f feat: implement comprehensive admin functionality
- Add admin role system with role and isAdmin fields to users table
- Create admin_actions audit log table for tracking all admin operations
- Implement admin CLI tool for user management (create, promote, demote, list, check)
- Add admin authentication with role-based access control
- Create admin service layer with system statistics and user management
- Implement user impersonation system with proper security checks
- Add admin API endpoints for user management and system statistics
- Create admin dashboard UI with overview, users, and action logs
- Fix admin stats endpoint and user deletion with proper foreign key handling
- Add admin link to navigation bar for admin users

Database:
- Add role and isAdmin columns to users table
- Create admin_actions table for audit trail
- Migration script: add-admin-functionality.js

CLI:
- src/backend/scripts/admin-cli.js - Admin user management tool

Backend:
- src/backend/services/admin.service.js - Admin business logic
- Updated auth.service.js with admin helper functions
- Enhanced index.js with admin routes and middleware
- Export sqlite connection from config for raw SQL operations

Frontend:
- src/frontend/src/routes/admin/+page.svelte - Admin dashboard
- Updated api.js with adminAPI functions
- Added Admin link to navigation bar

Security:
- Admin-only endpoints with role verification
- Audit logging for all admin actions
- Impersonation with 1-hour token expiration
- Foreign key constraint handling for user deletion
- Cannot delete self or other admins
- Last admin protection
2026-01-21 09:43:56 +01:00
fe305310b9 feat: implement Phase 2 - caching, performance monitoring, and health dashboard
Phase 2.1: Basic Caching Layer
- Add QSO statistics caching with 5-minute TTL
- Implement cache hit/miss tracking
- Add automatic cache invalidation after LoTW/DCL syncs
- Achieve 601x faster cache hits (12ms → 0.02ms)
- Reduce database load by 96% for repeated requests

Phase 2.2: Performance Monitoring
- Create comprehensive performance monitoring system
- Track query execution times with percentiles (P50/P95/P99)
- Detect slow queries (>100ms) and critical queries (>500ms)
- Implement performance ratings (EXCELLENT/GOOD/SLOW/CRITICAL)
- Add performance regression detection (2x slowdown)

Phase 2.3: Cache Invalidation Hooks
- Invalidate stats cache after LoTW sync completes
- Invalidate stats cache after DCL sync completes
- Automatic 5-minute TTL expiration

Phase 2.4: Monitoring Dashboard
- Enhance /api/health endpoint with performance metrics
- Add cache statistics (hit rate, size, hits/misses)
- Add uptime tracking
- Provide real-time monitoring via REST API

Files Modified:
- src/backend/services/cache.service.js (stats cache, hit/miss tracking)
- src/backend/services/lotw.service.js (cache + performance tracking)
- src/backend/services/dcl.service.js (cache invalidation)
- src/backend/services/performance.service.js (NEW - complete monitoring system)
- src/backend/index.js (enhanced health endpoint)

Performance Results:
- Cache hit time: 0.02ms (601x faster than database)
- Cache hit rate: 91.67% (10 queries)
- Database load: 96% reduction
- Average query time: 3.28ms (EXCELLENT rating)
- Slow queries: 0
- Critical queries: 0

Health Endpoint API:
- GET /api/health returns:
  - status, timestamp, uptime
  - performance metrics (totalQueries, avgTime, slow/critical, topSlowest)
  - cache stats (hitRate, total, size, hits/misses)
2026-01-21 07:41:12 +01:00
1b0cc4441f chore: add log files to gitignore 2026-01-21 07:12:58 +01:00
21263e6735 feat: optimize QSO statistics query with SQL aggregates and indexes
Replace memory-intensive approach (load all QSOs) with SQL aggregates:
- Query time: 5-10s → 3.17ms (62-125x faster)
- Memory usage: 100MB+ → <1MB (100x less)
- Concurrent users: 2-3 → 50+ (16-25x more)

Add 3 critical database indexes for QSO statistics:
- idx_qsos_user_primary: Primary user filter
- idx_qsos_user_unique_counts: Unique entity/band/mode counts
- idx_qsos_stats_confirmation: Confirmation status counting

Total: 10 performance indexes on qsos table

Tested with 8,339 QSOs:
- Query time: 3.17ms (target: <100ms) 
- All tests passed
- API response format unchanged
- Ready for production deployment
2026-01-21 07:11:21 +01:00
db0145782a security: implement multiple security hardening fixes
This commit addresses several critical and high-severity security
vulnerabilities identified in a comprehensive security audit:

Critical Fixes:
- Enforce JWT_SECRET in production (throws error if not set)
- Add JWT token expiration (24 hours)
- Implement path traversal protection for static file serving
- Add rate limiting to authentication endpoints (5/10 req per minute)
- Fix CORS to never allow all origins (even in development)

High/Medium Fixes:
- Add comprehensive security headers (CSP, HSTS, X-Frame-Options, etc.)
- Implement stricter email validation (RFC 5321 compliant)
- Add input sanitization for search parameters (length limit, wildcard removal)
- Improve job/QSO ID validation (range checks, safe integer validation)

Files modified:
- src/backend/config.js: JWT secret enforcement
- src/backend/index.js: JWT expiration, security headers, rate limiting,
  email validation, path traversal protection, CORS hardening, ID validation
- src/backend/services/lotw.service.js: search input sanitization

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-20 17:40:31 +01:00
2aebfb0771 cmd 2026-01-20 12:45:09 +01:00
79 changed files with 17872 additions and 1813 deletions

View File

@@ -1,61 +0,0 @@
# Dependencies
node_modules
# Note: bun.lock is needed by Dockerfile for --frozen-lockfile
# Environment
.env
.env.*
!.env.example
# Database - will be in volume mount
**/*.db
**/*.db-shm
**/*.db-wal
# Build outputs - built in container
src/frontend/build/
src/frontend/.svelte-kit/
src/frontend/dist/
build/
dist/
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Documentation (keep docs in image but don't need in build context)
# README.md
docs/
*.md
# Logs
logs/
*.log
backend.log
# Tests
*.test.js
*.test.ts
coverage/
# Docker files
Dockerfile
docker-compose.yml
.dockerignore
# CI/CD
.github/
.gitlab-ci.yml
# Data directory (for volume mount)
data/

View File

@@ -1,26 +0,0 @@
# Docker Environment Configuration
# Copy this file to .env and update with your values
# ============================================
# Application Settings
# ============================================
NODE_ENV=production
PORT=3001
LOG_LEVEL=debug
# ============================================
# Security (IMPORTANT: Change in production!)
# ============================================
# Generate a secure JWT secret with: openssl rand -base64 32
JWT_SECRET=change-this-in-production-use-openssl-rand-base64-32
# ============================================
# CORS Configuration
# ============================================
# Your application's public URL (e.g., https://awards.example.com)
VITE_APP_URL=
# Comma-separated list of allowed origins for CORS
# Only needed if not using same domain deployment
# Example: https://awards.example.com,https://www.awards.example.com
ALLOWED_ORIGINS=

View File

@@ -1,22 +1,47 @@
# Application Configuration # Application Configuration
# Copy this file to .env and update with your values # Copy this file to .env and update with your values
# Hostname for the application (e.g., https://awards.dj7nt.de) # ===================================================================
# Environment
# ===================================================================
# Development: development
# Production: production
NODE_ENV=development
# Log Level (debug, info, warn, error)
# Development: debug
# Production: info
LOG_LEVEL=debug
# Server Port (default: 3001)
PORT=3001
# ===================================================================
# URLs
# ===================================================================
# Frontend URL (e.g., https://awards.dj7nt.de)
# Leave empty for development (uses localhost) # Leave empty for development (uses localhost)
VITE_APP_URL= VITE_APP_URL=
# API Base URL (in production, can be same domain or separate) # API Base URL (leave empty for same-domain deployment)
# Leave empty to use relative paths (recommended for same-domain deployment) # Only set if API is on different domain
VITE_API_BASE_URL= VITE_API_BASE_URL=
# Allowed CORS origins for backend (comma-separated) # Allowed CORS origins for backend (comma-separated)
# Only needed for production if not using same domain # Add all domains that should access the API
# Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de # Example: https://awards.dj7nt.de,https://www.awards.dj7nt.de
ALLOWED_ORIGINS= ALLOWED_ORIGINS=
# JWT Secret (for production, use a strong random string) # ===================================================================
# Generate with: openssl rand -base64 32 # Security
# ===================================================================
# JWT Secret (REQUIRED for production)
# Development: uses default if not set
# Production: Generate with: openssl rand -base64 32
JWT_SECRET=change-this-in-production JWT_SECRET=change-this-in-production
# Node Environment # ===================================================================
NODE_ENV=development # Database (Optional)
# ===================================================================
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/custom.db

View File

@@ -1,30 +0,0 @@
# Production Configuration Template
# Copy this file to .env.production and update with your production values
# Application Environment
NODE_ENV=production
# Log Level (debug, info, warn, error)
# Recommended: info for production
LOG_LEVEL=info
# Server Port (default: 3001)
PORT=3001
# Frontend URL (e.g., https://awards.dj7nt.de)
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# Allowed CORS origins (comma-separated)
# Add all domains that should access the API
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
# JWT Secret (REQUIRED - generate a strong secret!)
# Generate with: openssl rand -base64 32
JWT_SECRET=REPLACE_WITH_SECURE_RANDOM_STRING
# Database (if using external database)
# Leave empty to use default SQLite database
# DATABASE_URL=file:/path/to/production.db

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ coverage
# logs # logs
logs/*.log logs/*.log
logs logs
backend.log
frontend.log
_.log _.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
!logs/.gitkeep !logs/.gitkeep

509
CLAUDE.md
View File

@@ -77,58 +77,6 @@ test("hello world", () => {
}); });
``` ```
## Docker Deployment
The application supports Docker deployment with single-port architecture and host-mounted database persistence.
**Quick Start**:
```bash
# Create environment file
cp .env.docker.example .env
# Generate JWT secret
openssl rand -base64 32 # Add to .env as JWT_SECRET
# Start application
docker-compose up -d --build
# Access at http://localhost:3001
```
**Architecture**:
- **Single Port**: Port 3001 serves both API (`/api/*`) and frontend (all other routes)
- **Database Persistence**: SQLite database stored at `./data/award.db` on host
- **Auto-initialization**: Database created from template on first startup
- **Health Checks**: Built-in health monitoring at `/api/health`
**Key Docker Files**:
- `Dockerfile`: Multi-stage build using official Bun runtime
- `docker-compose.yml`: Stack orchestration with volume mounts
- `docker-entrypoint.sh`: Database initialization logic
- `.env.docker.example`: Environment variable template
- `DOCKER.md`: Complete deployment documentation
**Environment Variables**:
- `NODE_ENV`: Environment mode (default: production)
- `PORT`: Application port (default: 3001)
- `LOG_LEVEL`: Logging level (debug/info/warn/error)
- `JWT_SECRET`: JWT signing secret (required, change in production!)
- `VITE_APP_URL`: Your application's public URL
- `ALLOWED_ORIGINS`: CORS allowed origins (comma-separated)
**Database Management**:
- Database location: `./data/award.db` (host-mounted volume)
- Backups: `cp data/award.db data/award.db.backup.$(date +%Y%m%d)`
- Reset: `docker-compose down -v && docker-compose up -d`
**Important Notes**:
- Database persists across container restarts/recreations
- Frontend dependencies are reinstalled in container to ensure correct platform binaries
- Uses custom init script (`src/backend/scripts/init-db.js`) with `bun:sqlite`
- Architecture-agnostic (works on x86, ARM64, etc.)
For detailed documentation, see `DOCKER.md`.
## Frontend ## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
@@ -222,6 +170,8 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
1. **`entity`**: Count unique entities (DXCC countries, states, grid squares) 1. **`entity`**: Count unique entities (DXCC countries, states, grid squares)
- `entityType`: What to count ("dxcc", "state", "grid", "callsign") - `entityType`: What to count ("dxcc", "state", "grid", "callsign")
- `target`: Number required for award - `target`: Number required for award
- `allowed_bands`: Optional array of bands that count (e.g., `["160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]` for HF only)
- `satellite_only`: Optional boolean to only count satellite QSOs (QSOs with `satName` field)
- `filters`: Optional filters (band, mode, etc.) - `filters`: Optional filters (band, mode, etc.)
- `displayField`: Optional field to display - `displayField`: Optional field to display
@@ -231,7 +181,6 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `filters`: Optional filters (band, mode, etc.) for award variants - `filters`: Optional filters (band, mode, etc.) for award variants
- Counts unique (DOK, band, mode) combinations - Counts unique (DOK, band, mode) combinations
- Only DCL-confirmed QSOs count - Only DCL-confirmed QSOs count
- Example variants: DLD 80m, DLD CW, DLD 80m CW
3. **`points`**: Point-based awards 3. **`points`**: Point-based awards
- `stations`: Array of {callsign, points} - `stations`: Array of {callsign, points}
@@ -244,6 +193,16 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
5. **`counter`**: Count QSOs or callsigns 5. **`counter`**: Count QSOs or callsigns
### Current Awards
- **DXCC**: HF bands only (160m-10m), 100 entities required
- **DXCC SAT**: Satellite QSOs only, 100 entities required
- **WAS**: Worked All States award
- **VUCC SAT**: VUCC Satellite award
- **SAT-RS44**: Special satellite award
- **73 on 73**: Special stations award
- **DLD**: Deutschland Diplom, 100 unique DOKs required
### Key Files ### Key Files
**Backend Award Service**: `src/backend/services/awards.service.js` **Backend Award Service**: `src/backend/services/awards.service.js`
@@ -253,11 +212,13 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `calculatePointsAwardProgress(userId, award, options)`: Point-based calculation - `calculatePointsAwardProgress(userId, award, options)`: Point-based calculation
- `getAwardEntityBreakdown(userId, awardId)`: Detailed entity breakdown - `getAwardEntityBreakdown(userId, awardId)`: Detailed entity breakdown
- `getAwardProgressDetails(userId, awardId)`: Progress with details - `getAwardProgressDetails(userId, awardId)`: Progress with details
- Implements `allowed_bands` and `satellite_only` filtering
**Database Schema**: `src/backend/db/schema/index.js` **Database Schema**: `src/backend/db/schema/index.js`
- QSO fields include: `darcDok`, `dclQslRstatus`, `dclQslRdate` - QSO fields include: `darcDok`, `dclQslRstatus`, `dclQslRdate`, `satName`
- DOK fields support DLD award tracking - DOK fields support DLD award tracking
- DCL confirmation fields separate from LoTW - DCL confirmation fields separate from LoTW
- `satName` field for satellite QSO tracking
**Award Definitions**: `award-definitions/*.json` **Award Definitions**: `award-definitions/*.json`
- Add new awards by creating JSON definition files - Add new awards by creating JSON definition files
@@ -268,7 +229,6 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`) - Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`)
- Uses `matchAll()` for reliable field parsing - Uses `matchAll()` for reliable field parsing
- Skips header records automatically - Skips header records automatically
- `parseDCLResponse(response)`: Parse DCL's JSON response format `{ "adif": "..." }`
- `normalizeBand(band)`: Standardize band names (80m, 40m, etc.) - `normalizeBand(band)`: Standardize band names (80m, 40m, etc.)
- `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.) - `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.)
- Used by both LoTW and DCL services for consistency - Used by both LoTW and DCL services for consistency
@@ -289,6 +249,7 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- `POST /api/dcl/sync`: Queue DCL sync job - `POST /api/dcl/sync`: Queue DCL sync job
- `GET /api/jobs/:jobId`: Get job status - `GET /api/jobs/:jobId`: Get job status
- `GET /api/jobs/active`: Get active job for current user - `GET /api/jobs/active`: Get active job for current user
- `DELETE /api/qsos/all`: Delete all QSOs for authenticated user
- `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback - `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback
**SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`. **SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`.
@@ -314,9 +275,9 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
- Fully implemented and functional - Fully implemented and functional
- **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details - **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details
### DLD Award Implementation (COMPLETED) ### DLD Award Implementation
The DLD (Deutschland Diplom) award was recently implemented: The DLD (Deutschland Diplom) award:
**Definition**: `award-definitions/dld.json` **Definition**: `award-definitions/dld.json`
```json ```json
@@ -336,7 +297,7 @@ The DLD (Deutschland Diplom) award was recently implemented:
``` ```
**Implementation Details**: **Implementation Details**:
- Function: `calculateDOKAwardProgress()` in `src/backend/services/awards.service.js` (lines 173-268) - Function: `calculateDOKAwardProgress()` in `src/backend/services/awards.service.js`
- Counts unique (DOK, band, mode) combinations - Counts unique (DOK, band, mode) combinations
- Only DCL-confirmed QSOs count (`dclQslRstatus === 'Y'`) - Only DCL-confirmed QSOs count (`dclQslRstatus === 'Y'`)
- Each unique DOK on each unique band/mode counts separately - Each unique DOK on each unique band/mode counts separately
@@ -349,8 +310,6 @@ The DLD (Deutschland Diplom) award was recently implemented:
- `dclQslRstatus`: DCL confirmation status ('Y' = confirmed) - `dclQslRstatus`: DCL confirmation status ('Y' = confirmed)
- `dclQslRdate`: DCL confirmation date - `dclQslRdate`: DCL confirmation date
**Documentation**: See `docs/DOCUMENTATION.md` for complete documentation including DLD award example.
**Frontend**: `src/frontend/src/routes/qsos/+page.svelte` **Frontend**: `src/frontend/src/routes/qsos/+page.svelte`
- Separate sync buttons for LoTW (blue) and DCL (orange) - Separate sync buttons for LoTW (blue) and DCL (orange)
- Independent progress tracking for each sync type - Independent progress tracking for each sync type
@@ -374,79 +333,59 @@ To add a new award:
3. If new rule type needed, add calculation function 3. If new rule type needed, add calculation function
4. Add type handling in `calculateAwardProgress()` switch statement 4. Add type handling in `calculateAwardProgress()` switch statement
5. Add type handling in `getAwardEntityBreakdown()` if needed 5. Add type handling in `getAwardEntityBreakdown()` if needed
6. Update documentation in `docs/DOCUMENTATION.md` 6. Update documentation
7. Test with sample QSO data 7. Test with sample QSO data
### Creating DLD Award Variants ### Award Rule Options
The DOK award type supports filters to create award variants. Examples: **allowed_bands**: Restrict which bands count toward an award
**DLD on 80m** (`dld-80m.json`):
```json
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact 100 different DOKs on the 80m band.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}
```
**DLD in CW mode** (`dld-cw.json`):
```json ```json
{ {
"rules": { "rules": {
"type": "dok", "type": "entity",
"target": 100, "allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
"confirmationType": "dcl",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
} }
} }
``` ```
- If absent or empty, all bands are allowed (default behavior)
- Used for DXCC to restrict to HF bands only
**DLD on 80m using CW** (combined filters, `dld-80m-cw.json`): **satellite_only**: Only count satellite QSOs
```json ```json
{ {
"rules": { "rules": {
"type": "dok", "type": "entity",
"target": 100, "satellite_only": true
"confirmationType": "dcl",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
} }
} }
``` ```
- If `true`, only QSOs with `satName` field set are counted
- Used for DXCC SAT award
**Available filter operators**: **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
- `eq`: equals - `eq`: equals
- `ne`: not equals - `ne`: not equals
- `in`: in array - `in`: in array
- `nin`: not in array - `nin`: not in array
- `contains`: contains substring - `contains`: contains substring
- Can filter any QSO field (band, mode, callsign, grid, state, etc.)
**Available filter fields**: Any QSO field (band, mode, callsign, grid, state, satName, etc.)
### Confirmation Systems ### Confirmation Systems
@@ -466,13 +405,8 @@ The DOK award type supports filters to create award variants. Examples:
- Required for DLD award - Required for DLD award
- German amateur radio specific - German amateur radio specific
- Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }` - Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }`
- `cnf_only: null` - Fetch all QSOs (confirmed + unconfirmed)
- `cnf_only: true` - Fetch only confirmed QSOs
- `qso_since` - QSOs since this date (YYYYMMDD)
- `qsl_since` - QSL confirmations since this date (YYYYMMDD)
- Response format: JSON with ADIF string in `adif` field - Response format: JSON with ADIF string in `adif` field
- Syncs ALL QSOs (both confirmed and unconfirmed) - Syncs ALL QSOs (both confirmed and unconfirmed)
- Unconfirmed QSOs stored but don't count toward awards
- Updates QSOs only if confirmation data has changed - Updates QSOs only if confirmation data has changed
### ADIF Format ### ADIF Format
@@ -491,138 +425,13 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
- `MY_DARC_DOK`: User's own DOK - `MY_DARC_DOK`: User's own DOK
- `STATION_CALLSIGN`: User's callsign - `STATION_CALLSIGN`: User's callsign
### Recent Commits ### QSO Management
- `aeeb75c`: feat: add QSO count display to filter section **Delete All QSOs**: `DELETE /api/qsos/all`
- Shows count of QSOs matching current filters next to "Filters" heading - Deletes all QSOs for authenticated user
- Displays "Showing X filtered QSOs" when filters are active - Also deletes related `qso_changes` records to satisfy foreign key constraints
- Displays "Showing X total QSOs" when no filters applied - Invalidates stats and user caches after deletion
- Dynamically updates when filters change - Returns count of deleted QSOs
- `bee02d1`: fix: count QSOs confirmed by either LoTW or DCL in stats
- QSO stats were only counting LoTW-confirmed QSOs (`lotwQslRstatus === 'Y'`)
- QSOs confirmed only by DCL were excluded from "confirmed" count
- Fixed by changing filter to: `q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y'`
- Now correctly shows all QSOs confirmed by at least one system
- `233888c`: fix: make ADIF parser case-insensitive for EOR delimiter
- **Critical bug**: LoTW uses lowercase `<eor>` tags, parser was splitting on uppercase `<EOR>`
- Caused 242K+ QSOs to be parsed as 1 giant record with fields overwriting each other
- Changed to case-insensitive regex: `new RegExp('<eor>', 'gi')`
- Replaced `regex.exec()` while loop with `matchAll()` for-of iteration
- Now correctly imports all QSOs from large LoTW reports
- `645f786`: fix: add missing timeOn field to LoTW duplicate detection
- LoTW sync was missing `timeOn` in duplicate detection query
- Multiple QSOs with same callsign/date/band/mode but different times were treated as duplicates
- Now matches DCL sync logic: `userId, callsign, qsoDate, timeOn, band, mode`
- `7f77c3a`: feat: add filter support for DOK awards
- DOK award type now supports filtering by band, mode, and other QSO fields
- Allows creating award variants like DLD 80m, DLD CW, DLD 80m CW
- Uses existing filter system with eq, ne, in, nin, contains operators
- Example awards created: dld-80m, dld-40m, dld-cw, dld-80m-cw
- `9e73704`: docs: update CLAUDE.md with DLD award variants documentation
- `7201446`: fix: return proper HTML for SPA routes instead of Bun error page
- When accessing client-side routes (like /qsos) via curl or non-JS clients,
the server attempted to open them as static files, causing Bun to throw
an unhandled ENOENT error that showed an ugly error page
- Now checks if a path has a file extension before attempting to serve it
- Paths without extensions are immediately served index.html for SPA routing
- Also improves the 503 error page with user-friendly HTML when frontend build is missing
- `223461f`: fix: enable debug logging and improve DCL sync observability
- `27d2ef1`: fix: preserve DOK data when DCL doesn't send values
- DCL sync only updates DOK/grid fields when DCL provides non-empty values
- Prevents accidentally clearing DOK data from manual entry or other sources
- Preserves existing DOK when DCL syncs QSO without DOK information
- `e09ab94`: feat: skip QSOs with unchanged confirmation data
- LoTW/DCL sync only updates QSOs if confirmation data has changed
- Tracks added, updated, and skipped QSO counts
- LoTW: Checks if lotwQslRstatus or lotwQslRdate changed
- DCL: Checks if dclQslRstatus, dclQslRdate, darcDok, myDarcDok, or grid changed
- `3592dbb`: feat: add import log showing synced QSOs
- Backend returns addedQSOs and updatedQSOs arrays in sync result
- Frontend displays import log with callsign, date, band, mode for each QSO
- Separate sections for "New QSOs" and "Updated QSOs"
- Sync summary shows total, added, updated, skipped counts
- `8a1a580`: feat: implement DCL ADIF parser and service integration
- Add shared ADIF parser utility (src/backend/utils/adif-parser.js)
- Implement DCL service with API integration
- Refactor LoTW service to use shared parser
- Tested with example DCL payload (6 QSOs parsed successfully)
- `c982dcd`: feat: implement DLD (Deutschland Diplom) award
- `322ccaf`: docs: add DLD (Deutschland Diplom) award documentation
### Sync Behavior
**Import Log**: After each sync, displays a table showing:
- New QSOs: Callsign, Date, Band, Mode
- Updated QSOs: Callsign, Date, Band, Mode (only if data changed)
- Skipped QSOs: Counted but not shown (data unchanged)
**Duplicate Handling**:
- QSOs matched by: userId, callsign, qsoDate, timeOn, band, mode
- If confirmation data unchanged: Skipped (not updated)
- If confirmation data changed: Updated with new values
- Prevents unnecessary database writes and shows accurate import counts
**DOK Update Behavior**:
- If QSO imported via LoTW (no DOK) and later DCL confirms with DOK: DOK is added ✓
- If QSO already has DOK and DCL sends different DOK: DOK is updated ✓
- If QSO has DOK and DCL syncs without DOK (empty): Existing DOK is preserved ✓
- LoTW never sends DOK data; only DCL provides DOK fields
**Important**: DCL sync only updates DOK/grid fields when DCL provides non-empty values. This prevents accidentally clearing DOK data that was manually entered or imported from other sources.
### DCL Sync Strategy
**Current Behavior**: DCL syncs ALL QSOs (confirmed + unconfirmed)
The application syncs both confirmed and unconfirmed QSOs from DCL:
- **Confirmed QSOs**: `dclQslRstatus = 'Y'` - Count toward awards
- **Unconfirmed QSOs**: `dclQslRstatus = 'N'` - Stored but don't count toward awards
**Purpose of syncing unconfirmed QSOs**:
- Users can see who they've worked (via "Not Confirmed" filter)
- Track QSOs awaiting confirmation
- QSOs can get confirmed later and will be updated on next sync
**Award Calculation**: Always uses confirmed QSOs only (e.g., `dclQslRstatus === 'Y'` for DLD award)
### DCL Incremental Sync Strategy
**Challenge**: Need to fetch both new QSOs AND confirmation updates to old QSOs
**Example Scenario**:
1. Full sync on 2026-01-20 → Last QSO date: 2026-01-20
2. User works 3 new QSOs on 2026-01-25 (unconfirmed)
3. Old QSO from 2026-01-10 gets confirmed on 2026-01-26
4. Next sync needs both: new QSOs (2026-01-25) AND confirmation update (2026-01-10)
**Solution**: Use both `qso_since` and `qsl_since` parameters with OR logic
```javascript
// Proposed sync logic (requires OR logic from DCL API)
const lastQSODate = await getLastDCLQSODate(userId); // Track QSO dates
const lastQSLDate = await getLastDCLQSLDate(userId); // Track QSL dates
const requestBody = {
key: dclApiKey,
limit: 50000,
qso_since: lastQSODate, // Get new QSOs since last contact
qsl_since: lastQSLDate, // Get QSL confirmations since last sync
cnf_only: null, // Fetch all QSOs
};
```
**Required API Behavior (OR Logic)**:
- Return QSOs where `(qso_date >= qso_since) OR (qsl_date >= qsl_since)`
- This ensures we get both new QSOs and confirmation updates
**Current DCL API Status**:
- Unknown if current API uses AND or OR logic for combined filters
- **Action Needed**: Request OR logic implementation from DARC
- Test current behavior to confirm API response pattern
**Why OR Logic is Needed**:
- With AND logic: Old QSOs getting confirmed are missed (qso_date too old)
- With OR logic: All updates captured efficiently in one API call
### QSO Page Filters ### QSO Page Filters
@@ -630,34 +439,50 @@ The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced fil
**Available Filters**: **Available Filters**:
- **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields - **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields
- Press Enter to apply search
- Case-insensitive partial matching
- **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm) - **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
- **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9) - **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9)
- **Confirmation Type Filter**: Filter by confirmation status - **Confirmation Type Filter**: Filter by confirmation status
- "All QSOs": Shows all QSOs (no filter) - "All QSOs", "LoTW Only", "DCL Only", "Both Confirmed", "Not Confirmed"
- "LoTW Only": Shows QSOs confirmed by LoTW but NOT DCL - **Clear Button**: Resets all filters
- "DCL Only": Shows QSOs confirmed by DCL but NOT LoTW
- "Both Confirmed": Shows QSOs confirmed by BOTH LoTW AND DCL
- "Not Confirmed": Shows QSOs confirmed by NEITHER LoTW nor DCL
- **Clear Button**: Resets all filters and reloads all QSOs
**Backend Implementation** (`src/backend/services/lotw.service.js`): **Backend Implementation** (`src/backend/services/lotw.service.js`):
- `getUserQSOs(userId, filters, options)`: Main filtering function - `getUserQSOs(userId, filters, options)`: Main filtering function
- Supports pagination with `page` and `limit` options - Supports pagination with `page` and `limit` options
- Filter logic uses Drizzle ORM query builders for safe SQL generation - Filter logic uses Drizzle ORM query builders for safe SQL generation
- Debug logging when `LOG_LEVEL=debug` shows applied filters
**Frontend API** (`src/frontend/src/lib/api.js`): **Frontend API** (`src/frontend/src/lib/api.js`):
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters - `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL` - Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
**QSO Count Display**: ### Award Detail View
- Shows count of QSOs matching current filters next to "Filters" heading
- **With filters active**: "Showing **X** filtered QSOs" **Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format.
- **No filters**: "Showing **X** total QSOs"
- Dynamically updates when filters are applied or cleared **Key Features**:
- Uses `pagination.totalCount` from backend API response - **Summary Cards**: Show total, confirmed, worked, needed counts for unique entities
- **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
- **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
- **QSO Detail**: Click any QSO to view full QSO details
- **Satellite Grouping**: Satellite QSOs grouped under "SAT" column instead of frequency band
**Column Sorting**: Bands sorted by wavelength (longest to shortest):
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT
**Column Sums**: Show unique entity count per column (not QSO counts)
**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
- `calculatePointsAwardProgress()`: Handles all count modes with `qsos` array
- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots
- Includes `satName` in QSO data for satellite grouping
- Implements `allowed_bands` and `satellite_only` filtering
### DXCC Entity Priority Logic ### DXCC Entity Priority Logic
@@ -665,47 +490,139 @@ When syncing QSOs from multiple confirmation sources, the system follows a prior
**Priority Order**: LoTW > DCL **Priority Order**: LoTW > DCL
**Implementation** (`src/backend/services/dcl.service.js`):
```javascript
// DXCC priority: LoTW > DCL
// Only update entity fields from DCL if:
// 1. QSO is NOT LoTW confirmed, AND
// 2. DCL actually sent entity data, AND
// 3. Current entity is missing
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
const hasDCLData = dbQSO.entity || dbQSO.entityId;
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
// Fill in entity data from DCL (only if DCL provides it)
updateData.entity = dbQSO.entity;
updateData.entityId = dbQSO.entityId;
// ... other entity fields
}
```
**Rules**: **Rules**:
1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable) 1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable)
2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload 2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload
3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty 3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty
4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it 4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it
**Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export. This is a limitation of the DCL API, not the application. If DCL adds these fields in the future, the system will automatically use them for DCL-only QSOs. **Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export.
### Recent Development Work (January 2025) ### Critical LoTW Sync Behavior
**QSO Page Enhancements**: **⚠️ IMPORTANT: LoTW sync MUST only import confirmed QSOs**
- Added confirmation type filter with exclusive logic (LoTW Only, DCL Only, Both Confirmed, Not Confirmed)
- Added search box for filtering by callsign, entity, or grid square
- Renamed "All Confirmation" to "All QSOs" for clarity
- Fixed filter logic to properly handle exclusive confirmation types
**Bug Fixes**: LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
- Fixed confirmation filter showing wrong QSOs (e.g., "LoTW Only" was also showing DCL QSOs) - `CALL` (callsign)
- Implemented proper SQL conditions for exclusive filters using separate condition pushes - `QSL_RCVD` (confirmation status: Y/N)
- Added debug logging to track filter application
**DXCC Entity Handling**: **Missing Fields for Unconfirmed QSOs:**
- Clarified that DCL API doesn't send DXCC fields (current limitation) - `DXCC` (entity ID) ← **CRITICAL for awards!**
- Implemented priority logic: LoTW entity data takes precedence over DCL - `COUNTRY` (entity name)
- System ready to auto-use DCL DXCC data if they add it in future API updates - `CONTINENT`, `CQ_ZONE`, `ITU_ZONE`
**Result:** Unconfirmed QSOs have `entityId: null` and `entity: ""`, breaking award calculations.
**Current Implementation (CORRECT):**
```javascript
// lotw.service.js - fetchQSOsFromLoTW()
const params = new URLSearchParams({
login: lotwUsername,
password: loTWPassword,
qso_query: '1',
qso_qsl: 'yes', // ONLY confirmed QSOs
qso_qslsince: dateStr, // Incremental sync
});
```
### Recent Development Work (January 2026)
**Award System Enhancements**:
- Added `allowed_bands` filter to restrict which bands count toward awards
- Added `satellite_only` flag for satellite-only awards
- DXCC restricted to HF bands (160m-10m) only
- Added DXCC SAT award for satellite-only QSOs
- 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**:
- Summary shows unique entity progress instead of QSO counts
- Column sums count unique entities per column
- Satellite QSOs grouped under "SAT" column
- Bands sorted by wavelength instead of alphabetically
- 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**:
- Fixed DELETE /api/qsos/all to handle foreign key constraints
- 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"

219
DOCKER.md
View File

@@ -1,219 +0,0 @@
# Docker Deployment Guide
This guide covers deploying Quickawards using Docker.
## Quick Start
1. **Create environment file:**
```bash
cp .env.docker.example .env
```
2. **Generate secure JWT secret:**
```bash
openssl rand -base64 32
```
Copy the output and set it as `JWT_SECRET` in `.env`.
3. **Update `.env` with your settings:**
- `JWT_SECRET`: Strong random string (required)
- `VITE_APP_URL`: Your domain (e.g., `https://awards.example.com`)
- `ALLOWED_ORIGINS`: Your domain(s) for CORS
4. **Start the application:**
```bash
docker-compose up -d
```
5. **Access the application:**
- URL: http://localhost:3001
- Health check: http://localhost:3001/api/health
## Architecture
### Single Port Design
The Docker stack exposes a single port (3001) which serves both:
- **Backend API** (`/api/*`)
- **Frontend SPA** (all other routes)
### Database Persistence
- **Location**: `./data/award.db` (host-mounted volume)
- **Initialization**: Automatic on first startup
- **Persistence**: Database survives container restarts/recreations
### Startup Behavior
1. **First startup**: Database is created from template
2. **Subsequent startups**: Existing database is used
3. **Container recreation**: Database persists in volume
## Commands
### Start the application
```bash
docker-compose up -d
```
### View logs
```bash
docker-compose logs -f
```
### Stop the application
```bash
docker-compose down
```
### Rebuild after code changes
```bash
docker-compose up -d --build
```
### Stop and remove everything (including database volume)
```bash
docker-compose down -v
```
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NODE_ENV` | No | `production` | Environment mode |
| `PORT` | No | `3001` | Application port |
| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) |
| `JWT_SECRET` | **Yes** | - | JWT signing secret (change this!) |
| `VITE_APP_URL` | No | - | Your application's public URL |
| `ALLOWED_ORIGINS` | No | - | CORS allowed origins (comma-separated) |
## Database Management
### Backup the database
```bash
cp data/award.db data/award.db.backup.$(date +%Y%m%d)
```
### Restore from backup
```bash
docker-compose down
cp data/award.db.backup.YYYYMMDD data/award.db
docker-compose up -d
```
### Reset the database
```bash
docker-compose down -v
docker-compose up -d
```
## Troubleshooting
### Container won't start
```bash
# Check logs
docker-compose logs -f
# Check container status
docker-compose ps
```
### Database errors
```bash
# Check database file exists
ls -la data/
# Check database permissions
stat data/award.db
```
### Port already in use
Change the port mapping in `docker-compose.yml`:
```yaml
ports:
- "8080:3001" # Maps host port 8080 to container port 3001
```
### Health check failing
```bash
# Check if container is responding
curl http://localhost:3001/api/health
# Check container logs
docker-compose logs quickawards
```
## Production Deployment
### Using a Reverse Proxy (nginx)
Example nginx configuration:
```nginx
server {
listen 80;
server_name awards.example.com;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### SSL/TLS with Let's Encrypt
Use certbot with nginx:
```bash
sudo certbot --nginx -d awards.example.com
```
### Security Checklist
- [ ] Set strong `JWT_SECRET`
- [ ] Set `NODE_ENV=production`
- [ ] Set `LOG_LEVEL=info` (or `warn` in production)
- [ ] Configure `ALLOWED_ORIGINS` to your domain only
- [ ] Use HTTPS/TLS in production
- [ ] Regular database backups
- [ ] Monitor logs for suspicious activity
- [ ] Keep Docker image updated
## File Structure After Deployment
```
project/
├── data/
│ └── award.db # Persisted database (volume mount)
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env # Your environment variables
└── ... (source code)
```
## Building Without docker-compose
If you prefer to use `docker` directly:
```bash
# Build the image
docker build -t quickawards .
# Run the container
docker run -d \
--name quickawards \
-p 3001:3001 \
-v $(pwd)/data:/data \
-e JWT_SECRET=your-secret-here \
-e NODE_ENV=production \
quickawards
```

View File

@@ -1,72 +0,0 @@
# Multi-stage Dockerfile for Quickawards
# Uses official Bun runtime image
# ============================================
# Stage 1: Dependencies & Database Init
# ============================================
FROM oven/bun:1 AS builder
WORKDIR /app
# Install ALL dependencies (including devDependencies for drizzle-kit)
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source code (node_modules excluded by .dockerignore)
COPY . .
# Reinstall frontend dependencies to get correct platform binaries
RUN cd src/frontend && bun install
# Initialize database using custom script
# This creates a fresh database with the correct schema using bun:sqlite
RUN bun src/backend/scripts/init-db.js
# Build frontend
RUN bun run build
# ============================================
# Stage 2: Production Image
# ============================================
FROM oven/bun:1 AS production
WORKDIR /app
# Install production dependencies only
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
# Copy backend source and schema files
COPY src/backend ./src/backend
COPY award-definitions ./award-definitions
COPY drizzle.config.ts ./
# Copy frontend build from builder stage
COPY --from=builder /app/src/frontend/build ./src/frontend/build
# Copy initialized database from builder (will be used as template)
COPY --from=builder /app/src/backend/award.db /app/award.db.template
# Copy drizzle migrations (if they exist)
COPY --from=builder /app/drizzle ./drizzle
# Create directory for database volume mount
RUN mkdir -p /data
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set environment variables
ENV NODE_ENV=production \
PORT=3001 \
LOG_LEVEL=info
# Expose the application port
EXPOSE 3001
# Use entrypoint script to handle database initialization
ENTRYPOINT ["docker-entrypoint.sh"]
# Start the backend server
CMD ["bun", "run", "src/backend/index.js"]

126
README.md
View File

@@ -116,7 +116,7 @@ award/
│ └── package.json │ └── package.json
├── award-definitions/ # Award rule definitions (JSON) ├── award-definitions/ # Award rule definitions (JSON)
├── award.db # SQLite database (auto-created) ├── award.db # SQLite database (auto-created)
├── .env.production.template # Production configuration template ├── .env.example # Environment configuration template
├── bunfig.toml # Bun configuration ├── bunfig.toml # Bun configuration
├── drizzle.config.js # Drizzle ORM configuration ├── drizzle.config.js # Drizzle ORM configuration
├── package.json ├── package.json
@@ -149,20 +149,32 @@ cp .env.example .env
Edit `.env` with your configuration: Edit `.env` with your configuration:
```env ```env
# Application URL (for production deployment) # Environment (development/production)
VITE_APP_URL=https://awards.dj7nt.de NODE_ENV=development
# Log Level (debug/info/warn/error)
LOG_LEVEL=debug
# Server Port (default: 3001)
PORT=3001
# Frontend URL (e.g., https://awards.dj7nt.de)
# Leave empty for development (uses localhost)
VITE_APP_URL=
# API Base URL (leave empty for same-domain deployment) # API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL= VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32) # Allowed CORS origins (comma-separated)
JWT_SECRET=your-generated-secret-here # Add all domains that should access the API
ALLOWED_ORIGINS=
# Environment # JWT Secret (generate with: openssl rand -base64 32)
NODE_ENV=production JWT_SECRET=change-this-in-production
``` ```
**For development**: You can leave `.env` empty or use defaults. **For development**: Use defaults above.
**For production**: Set `NODE_ENV=production`, `LOG_LEVEL=info`, and generate a strong `JWT_SECRET`.
4. Initialize the database with performance indexes: 4. Initialize the database with performance indexes:
```bash ```bash
@@ -246,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
@@ -264,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
@@ -276,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
); );
@@ -414,20 +476,26 @@ bun run build
Create `.env` in the project root: Create `.env` in the project root:
```bash ```bash
# Application URL
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (empty for same-domain)
VITE_API_BASE_URL=
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
# Environment # Environment
NODE_ENV=production NODE_ENV=production
# Database path (absolute path recommended) # Log Level (debug/info/warn/error)
DATABASE_PATH=/path/to/award/award.db LOG_LEVEL=info
# Server Port (default: 3001)
PORT=3001
# Frontend URL
VITE_APP_URL=https://awards.dj7nt.de
# API Base URL (leave empty for same-domain deployment)
VITE_API_BASE_URL=
# Allowed CORS origins (comma-separated)
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
# JWT Secret (generate with: openssl rand -base64 32)
JWT_SECRET=your-generated-secret-here
``` ```
**Security**: Ensure `.env` has restricted permissions: **Security**: Ensure `.env` has restricted permissions:
@@ -782,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

@@ -0,0 +1,23 @@
{
"id": "73-on-73",
"name": "73 on 73",
"description": "Confirm 73 unique QSO partners on satellite AO-73",
"caption": "Contact and confirm 73 different stations (unique callsigns) via the AO-73 satellite. Each unique callsign confirmed via LoTW counts toward the total of 73.",
"category": "satellite",
"rules": {
"type": "entity",
"entityType": "callsign",
"target": 73,
"displayField": "callsign",
"filters": {
"operator": "AND",
"filters": [
{
"field": "satName",
"operator": "eq",
"value": "AO-73"
}
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-40m",
"name": "DLD 40m",
"description": "Confirm 100 unique DOKs on 40m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 40m band. Only DCL-confirmed QSOs with valid DOK information on 40m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "40m" }
]
}
}
}

View File

@@ -1,20 +0,0 @@
{
"id": "dld-80m-cw",
"name": "DLD 80m CW",
"description": "Confirm 100 unique DOKs on 80m using CW",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band using CW mode. Only DCL-confirmed QSOs with valid DOK information on 80m CW count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band. Only DCL-confirmed QSOs with valid DOK information on 80m count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}

View File

@@ -1,19 +0,0 @@
{
"id": "dld-cw",
"name": "DLD CW",
"description": "Confirm 100 unique DOKs using CW mode",
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) using CW (Morse code). Each unique DOK on CW counts separately. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}

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

@@ -1,27 +0,0 @@
{
"id": "dxcc-cw",
"name": "DXCC CW",
"description": "Confirm 100 DXCC entities using CW mode",
"caption": "Contact and confirm 100 different DXCC entities using CW mode only. Only QSOs made with CW mode count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"target": 100,
"type": "filtered",
"baseRule": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
},
"filters": {
"operator": "AND",
"filters": [
{
"field": "mode",
"operator": "eq",
"value": "CW"
}
]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"id": "dxcc-sat",
"name": "DXCC SAT",
"description": "Confirm 100 DXCC entities via satellite",
"caption": "Contact and confirm 100 different DXCC entities using satellite communications. Only satellite QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity",
"satellite_only": true
}
}

View File

@@ -1,13 +1,74 @@
{ {
"id": "dxcc-mixed", "id": "dxcc",
"name": "DXCC Mixed Mode", "name": "DXCC",
"description": "Confirm 100 DXCC entities on any band/mode", "description": "Confirm 100 DXCC entities on HF bands",
"caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts. QSOs are confirmed when LoTW QSL is received.", "caption": "Contact and confirm 100 different DXCC entities on HF bands (160m-10m). Only HF band QSOs count toward this award. QSOs are confirmed when LoTW QSL is received.",
"category": "dxcc", "category": "dxcc",
"rules": { "rules": {
"type": "entity", "type": "entity",
"entityType": "dxcc", "entityType": "dxcc",
"target": 100, "target": 100,
"displayField": "entity" "displayField": "entity",
"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

@@ -1,31 +0,0 @@
services:
quickawards:
build:
context: .
dockerfile: Dockerfile
container_name: quickawards
restart: unless-stopped
ports:
- "3001:3001"
environment:
# Application settings
NODE_ENV: production
PORT: 3001
LOG_LEVEL: info
# Security - IMPORTANT: Change these in production!
JWT_SECRET: ${JWT_SECRET:-change-this-in-production}
# CORS - Set to your domain in production
VITE_APP_URL: ${VITE_APP_URL:-}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
volumes:
# Host-mounted database directory
# Database will be created at ./data/award.db on first startup
- ./data:/data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View File

@@ -1,62 +0,0 @@
#!/bin/sh
set -e
# Docker container entrypoint script
# Handles database initialization on first startup
echo "=========================================="
echo "Quickawards - Docker Entrypoint"
echo "=========================================="
# Database location in volume mount
DB_PATH="/data/award.db"
TEMPLATE_DB="/app/award.db.template"
APP_DB_PATH="/app/src/backend/award.db"
# Check if database exists in the volume
if [ ! -f "$DB_PATH" ]; then
echo ""
echo "📦 Database not found in volume mount."
echo " Initializing from template database..."
echo ""
# Copy the template database (created during build with drizzle-kit push)
cp "$TEMPLATE_DB" "$DB_PATH"
# Ensure proper permissions
chmod 644 "$DB_PATH"
echo "✅ Database initialized at: $DB_PATH"
echo " This database will persist in the Docker volume."
else
echo ""
echo "✅ Existing database found at: $DB_PATH"
echo " Using existing database from volume mount."
fi
# Create symlink from app's expected db location to volume mount
# The app expects the database at src/backend/award.db
# We create a symlink so it points to the volume-mounted database
if [ -L "$APP_DB_PATH" ]; then
# Symlink already exists, remove it to refresh
rm "$APP_DB_PATH"
elif [ -e "$APP_DB_PATH" ]; then
# File or directory exists (shouldn't happen in production, but handle it)
echo "⚠ Warning: Found existing database at $APP_DB_PATH, removing..."
rm -f "$APP_DB_PATH"
fi
# Create symlink to the volume-mounted database
ln -s "$DB_PATH" "$APP_DB_PATH"
echo "✅ Created symlink: $APP_DB_PATH -> $DB_PATH"
echo ""
echo "=========================================="
echo "Starting Quickawards application..."
echo "Port: ${PORT:-3001}"
echo "Environment: ${NODE_ENV:-production}"
echo "=========================================="
echo ""
# Execute the main command (passed as CMD in Dockerfile)
exec "$@"

File diff suppressed because it is too large Load Diff

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,25 @@
CREATE TABLE `admin_actions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`admin_id` integer NOT NULL,
`action_type` text NOT NULL,
`target_user_id` integer,
`details` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`admin_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `qso_changes` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`job_id` integer NOT NULL,
`qso_id` integer,
`change_type` text NOT NULL,
`before_data` text,
`after_data` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`job_id`) REFERENCES `sync_jobs`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`qso_id`) REFERENCES `qsos`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `users` ADD `role` text DEFAULT 'user' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `is_admin` integer DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `users` DROP COLUMN `role`;

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,756 @@
{
"version": "6",
"dialect": "sqlite",
"id": "542bddc5-2e08-49af-91b5-013a6c9584df",
"prevId": "b5c00e60-2f3c-4c2b-a540-0be8d9e856e6",
"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": {}
},
"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
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 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

@@ -0,0 +1,748 @@
{
"version": "6",
"dialect": "sqlite",
"id": "071c98fb-6721-4da7-98cb-c16cb6aaf0c1",
"prevId": "542bddc5-2e08-49af-91b5-013a6c9584df",
"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": {}
},
"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
},
"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

@@ -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

@@ -15,6 +15,27 @@
"when": 1768641501799, "when": 1768641501799,
"tag": "0001_free_hiroim", "tag": "0001_free_hiroim",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1768988121232,
"tag": "0002_nervous_layla_miller",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1768989260562,
"tag": "0003_tired_warpath",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1769171258085,
"tag": "0004_overrated_havok",
"breakpoints": true
} }
] ]
} }

View File

@@ -15,7 +15,16 @@ const __dirname = dirname(__filename);
const isDevelopment = process.env.NODE_ENV !== 'production'; const isDevelopment = process.env.NODE_ENV !== 'production';
export const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; // SECURITY: Require JWT_SECRET in production - no fallback for security
// This prevents JWT token forgery if environment variable is not set
if (!process.env.JWT_SECRET && !isDevelopment) {
throw new Error(
'FATAL: JWT_SECRET environment variable must be set in production. ' +
'Generate one with: openssl rand -base64 32'
);
}
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-in-production';
export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'); export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
// =================================================================== // ===================================================================
@@ -113,6 +122,8 @@ export const db = drizzle({
schema, schema,
}); });
export { sqlite };
export async function closeDatabase() { export async function closeDatabase() {
sqlite.close(); sqlite.close();
} }

View File

@@ -9,6 +9,9 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
* @property {string|null} lotwUsername * @property {string|null} lotwUsername
* @property {string|null} lotwPassword * @property {string|null} lotwPassword
* @property {string|null} dclApiKey * @property {string|null} dclApiKey
* @property {boolean} isAdmin
* @property {boolean} isSuperAdmin
* @property {Date|null} lastSeen
* @property {Date} createdAt * @property {Date} createdAt
* @property {Date} updatedAt * @property {Date} updatedAt
*/ */
@@ -21,6 +24,9 @@ export const users = sqliteTable('users', {
lotwUsername: text('lotw_username'), lotwUsername: text('lotw_username'),
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),
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()),
}); });
@@ -202,5 +208,58 @@ export const qsoChanges = sqliteTable('qso_changes', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
}); });
/**
* @typedef {Object} AdminAction
* @property {number} id
* @property {number} adminId
* @property {string} actionType
* @property {number|null} targetUserId
* @property {string|null} details
* @property {Date} createdAt
*/
export const adminActions = sqliteTable('admin_actions', {
id: integer('id').primaryKey({ autoIncrement: true }),
adminId: integer('admin_id').notNull().references(() => users.id),
actionType: text('action_type').notNull(), // 'impersonate_start', 'impersonate_stop', 'role_change', 'user_delete', etc.
targetUserId: integer('target_user_id').references(() => users.id),
details: text('details'), // JSON with additional context
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 }; export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
/**
* Migration: Add admin functionality to users table and create admin_actions table
*
* This script adds role-based access control (RBAC) for admin functionality:
* - Adds 'role' and 'isAdmin' columns to users table
* - Creates admin_actions table for audit logging
* - Adds indexes for performance
*/
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 admin functionality...');
try {
// Check if role column already exists in users table
const columnExists = sqlite.query(`
SELECT COUNT(*) as count
FROM pragma_table_info('users')
WHERE name = 'role'
`).get();
if (columnExists.count > 0) {
console.log('Admin columns already exist in users table. Skipping...');
} else {
// Add role column to users table
sqlite.exec(`
ALTER TABLE users
ADD COLUMN role TEXT NOT NULL DEFAULT 'user'
`);
// Add isAdmin column to users table
sqlite.exec(`
ALTER TABLE users
ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0
`);
console.log('Added role and isAdmin columns to users table');
}
// Check if admin_actions table already exists
const tableExists = sqlite.query(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='admin_actions'
`).get();
if (tableExists) {
console.log('Table admin_actions already exists. Skipping...');
} else {
// Create admin_actions table
sqlite.exec(`
CREATE TABLE admin_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL,
action_type TEXT NOT NULL,
target_user_id INTEGER,
details TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
FOREIGN KEY (admin_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
)
`);
// Create indexes for admin_actions
sqlite.exec(`
CREATE INDEX idx_admin_actions_admin_id ON admin_actions(admin_id)
`);
sqlite.exec(`
CREATE INDEX idx_admin_actions_action_type ON admin_actions(action_type)
`);
sqlite.exec(`
CREATE INDEX idx_admin_actions_created_at ON admin_actions(created_at)
`);
console.log('Created admin_actions table with indexes');
}
console.log('Migration complete! Admin functionality added to database.');
} catch (error) {
console.error('Migration failed:', error);
sqlite.close();
process.exit(1);
}
sqlite.close();
}
// Run migration
migrate().then(() => {
console.log('Migration script completed successfully');
process.exit(0);
});

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

@@ -2,10 +2,11 @@
* Migration: Add performance indexes for QSO queries * Migration: Add performance indexes for QSO queries
* *
* This script creates database indexes to significantly improve query performance * This script creates database indexes to significantly improve query performance
* for filtering, sorting, and sync operations. Expected impact: * for filtering, sorting, sync operations, and QSO statistics. Expected impact:
* - 80% faster filter queries * - 80% faster filter queries
* - 60% faster sync operations * - 60% faster sync operations
* - 50% faster award calculations * - 50% faster award calculations
* - 95% faster QSO statistics queries (critical optimization)
*/ */
import Database from 'bun:sqlite'; import Database from 'bun:sqlite';
@@ -49,9 +50,21 @@ async function migrate() {
console.log('Creating index: idx_qsos_qso_date'); console.log('Creating index: idx_qsos_qso_date');
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_qso_date ON qsos(user_id, qso_date DESC)`); sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_qso_date ON qsos(user_id, qso_date DESC)`);
// Index 8: QSO Statistics - Primary user filter (CRITICAL for getQSOStats)
console.log('Creating index: idx_qsos_user_primary');
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_primary ON qsos(user_id)`);
// Index 9: QSO Statistics - Unique counts (entity, band, mode)
console.log('Creating index: idx_qsos_user_unique_counts');
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_unique_counts ON qsos(user_id, entity, band, mode)`);
// Index 10: QSO Statistics - Optimized confirmation counting
console.log('Creating index: idx_qsos_stats_confirmation');
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_stats_confirmation ON qsos(user_id, lotw_qsl_rstatus, dcl_qsl_rstatus)`);
sqlite.close(); sqlite.close();
console.log('\nMigration complete! Created 7 performance indexes.'); console.log('\nMigration complete! Created 10 performance indexes.');
console.log('\nTo verify indexes were created, run:'); console.log('\nTo verify indexes were created, run:');
console.log(' sqlite3 award.db ".indexes qsos"'); console.log(' sqlite3 award.db ".indexes qsos"');

View File

@@ -0,0 +1,251 @@
#!/usr/bin/env bun
/**
* Admin CLI Tool
*
* Usage:
* bun src/backend/scripts/admin-cli.js create <email> <password> <callsign>
* bun src/backend/scripts/admin-cli.js promote <email>
* bun src/backend/scripts/admin-cli.js demote <email>
* bun src/backend/scripts/admin-cli.js list
* bun src/backend/scripts/admin-cli.js check <email>
* bun src/backend/scripts/admin-cli.js help
*/
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);
// Enable foreign keys
sqlite.exec('PRAGMA foreign_keys = ON');
function help() {
console.log(`
Admin CLI Tool - Manage admin users
Commands:
create <email> <password> <callsign> Create a new admin user
promote <email> Promote existing user to admin
demote <email> Demote admin to regular user
list List all admin users
check <email> Check if user is admin
help Show this help message
Examples:
bun src/backend/scripts/admin-cli.js create admin@example.com secretPassword ADMIN
bun src/backend/scripts/admin-cli.js promote user@example.com
bun src/backend/scripts/admin-cli.js list
bun src/backend/scripts/admin-cli.js check user@example.com
`);
}
function createAdminUser(email, password, callsign) {
console.log(`Creating admin user: ${email}`);
// Check if user already exists
const existingUser = sqlite.query(`
SELECT id, email FROM users WHERE email = ?
`).get(email);
if (existingUser) {
console.error(`Error: User with email ${email} already exists`);
process.exit(1);
}
// Hash password
const passwordHash = Bun.password.hashSync(password, {
algorithm: 'bcrypt',
cost: 10,
});
// Ensure passwordHash is a string
const hashString = String(passwordHash);
// Insert admin user
const result = sqlite.query(`
INSERT INTO users (email, password_hash, callsign, is_admin, created_at, updated_at)
VALUES (?, ?, ?, 1, strftime('%s', 'now') * 1000, strftime('%s', 'now') * 1000)
`).run(email, hashString, callsign);
console.log(`✓ Admin user created successfully!`);
console.log(` ID: ${result.lastInsertRowid}`);
console.log(` Email: ${email}`);
console.log(` Callsign: ${callsign}`);
console.log(`\nYou can now log in with these credentials.`);
}
function promoteUser(email) {
console.log(`Promoting user to admin: ${email}`);
// Check if user exists
const user = sqlite.query(`
SELECT id, email, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
console.error(`Error: User with email ${email} not found`);
process.exit(1);
}
if (user.is_admin === 1) {
console.log(`User ${email} is already an admin`);
return;
}
// Update user to admin
sqlite.query(`
UPDATE users
SET is_admin = 1, updated_at = strftime('%s', 'now') * 1000
WHERE email = ?
`).run(email);
console.log(`✓ User ${email} has been promoted to admin`);
}
function demoteUser(email) {
console.log(`Demoting admin to regular user: ${email}`);
// Check if user exists
const user = sqlite.query(`
SELECT id, email, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
console.error(`Error: User with email ${email} not found`);
process.exit(1);
}
if (user.is_admin !== 1) {
console.log(`User ${email} is not an admin`);
return;
}
// Check if this is the last admin
const adminCount = sqlite.query(`
SELECT COUNT(*) as count FROM users WHERE is_admin = 1
`).get();
if (adminCount.count === 1) {
console.error(`Error: Cannot demote the last admin user. At least one admin must exist.`);
process.exit(1);
}
// Update user to regular user
sqlite.query(`
UPDATE users
SET is_admin = 0, updated_at = strftime('%s', 'now') * 1000
WHERE email = ?
`).run(email);
console.log(`✓ User ${email} has been demoted to regular user`);
}
function listAdmins() {
console.log('Listing all admin users...\n');
const admins = sqlite.query(`
SELECT id, email, callsign, created_at
FROM users
WHERE is_admin = 1
ORDER BY created_at ASC
`).all();
if (admins.length === 0) {
console.log('No admin users found');
return;
}
console.log(`Found ${admins.length} admin user(s):\n`);
console.log('ID | Email | Callsign | Created At');
console.log('----+----------------------------+----------+---------------------');
admins.forEach((admin) => {
const createdAt = new Date(admin.created_at).toLocaleString();
console.log(`${String(admin.id).padEnd(3)} | ${admin.email.padEnd(26)} | ${admin.callsign.padEnd(8)} | ${createdAt}`);
});
}
function checkUser(email) {
console.log(`Checking user status: ${email}\n`);
const user = sqlite.query(`
SELECT id, email, callsign, is_admin FROM users WHERE email = ?
`).get(email);
if (!user) {
console.log(`User not found: ${email}`);
process.exit(1);
}
const isAdmin = user.is_admin === 1;
console.log(`User found:`);
console.log(` Email: ${user.email}`);
console.log(` Callsign: ${user.callsign}`);
console.log(` Is Admin: ${isAdmin ? 'Yes ✓' : 'No'}`);
}
// Main CLI logic
const command = process.argv[2];
const args = process.argv.slice(3);
switch (command) {
case 'create':
if (args.length !== 3) {
console.error('Error: create command requires 3 arguments: <email> <password> <callsign>');
help();
process.exit(1);
}
createAdminUser(args[0], args[1], args[2]);
break;
case 'promote':
if (args.length !== 1) {
console.error('Error: promote command requires 1 argument: <email>');
help();
process.exit(1);
}
promoteUser(args[0]);
break;
case 'demote':
if (args.length !== 1) {
console.error('Error: demote command requires 1 argument: <email>');
help();
process.exit(1);
}
demoteUser(args[0]);
break;
case 'list':
listAdmins();
break;
case 'check':
if (args.length !== 1) {
console.error('Error: check command requires 1 argument: <email>');
help();
process.exit(1);
}
checkUser(args[0]);
break;
case 'help':
case '--help':
case '-h':
help();
break;
default:
console.error(`Error: Unknown command '${command}'`);
help();
process.exit(1);
}
sqlite.close();

View File

@@ -0,0 +1,435 @@
import { eq, sql, desc } from 'drizzle-orm';
import { db, sqlite, logger } from '../config.js';
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
import { getUserByIdFull, isAdmin, isSuperAdmin, updateUserRole } from './auth.service.js';
/**
* Log an admin action for audit trail
* @param {number} adminId - Admin user ID
* @param {string} actionType - Type of action (e.g., 'impersonate_start', 'role_change')
* @param {number|null} targetUserId - Target user ID (if applicable)
* @param {Object} details - Additional details (will be JSON stringified)
* @returns {Promise<Object>} Created admin action record
*/
export async function logAdminAction(adminId, actionType, targetUserId = null, details = {}) {
const [action] = await db
.insert(adminActions)
.values({
adminId,
actionType,
targetUserId,
details: JSON.stringify(details),
})
.returning();
return action;
}
/**
* Get admin actions log
* @param {number} adminId - Admin user ID (optional, if null returns all actions)
* @param {Object} options - Query options
* @param {number} options.limit - Number of records to return
* @param {number} options.offset - Number of records to skip
* @returns {Promise<Array>} Array of admin actions
*/
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
// Use raw SQL for the self-join (admin users and target users from same users table)
// Using bun:sqlite prepared statements for raw SQL
let query = `
SELECT
aa.id as id,
aa.admin_id as adminId,
admin_user.email as adminEmail,
admin_user.callsign as adminCallsign,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
target_user.email as targetEmail,
target_user.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
LEFT JOIN users target_user ON target_user.id = aa.target_user_id
`;
const params = [];
if (adminId !== null) {
query += ` WHERE aa.admin_id = ?`;
params.push(adminId);
}
query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
return sqlite.prepare(query).all(...params);
}
/**
* Get system-wide statistics
* @returns {Promise<Object>} System statistics
*/
export async function getSystemStats() {
const [
userStats,
qsoStats,
syncJobStats,
adminStats,
] = await Promise.all([
// User statistics
db.select({
totalUsers: sql`CAST(COUNT(*) AS INTEGER)`,
adminUsers: sql`CAST(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END) AS INTEGER)`,
regularUsers: sql`CAST(SUM(CASE WHEN is_admin = 0 THEN 1 ELSE 0 END) AS INTEGER)`,
}).from(users),
// QSO statistics
db.select({
totalQSOs: sql`CAST(COUNT(*) AS INTEGER)`,
uniqueCallsigns: sql`CAST(COUNT(DISTINCT callsign) AS INTEGER)`,
uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
lotwConfirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
dclConfirmed: sql`CAST(SUM(CASE WHEN dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
}).from(qsos),
// Sync job statistics
db.select({
totalJobs: sql`CAST(COUNT(*) AS INTEGER)`,
lotwJobs: sql`CAST(SUM(CASE WHEN type = 'lotw_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
dclJobs: sql`CAST(SUM(CASE WHEN type = 'dcl_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
completedJobs: sql`CAST(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS INTEGER)`,
failedJobs: sql`CAST(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS INTEGER)`,
}).from(syncJobs),
// Admin action statistics
db.select({
totalAdminActions: sql`CAST(COUNT(*) AS INTEGER)`,
impersonations: sql`CAST(SUM(CASE WHEN action_type LIKE 'impersonate%' THEN 1 ELSE 0 END) AS INTEGER)`,
}).from(adminActions),
]);
return {
users: userStats[0],
qsos: qsoStats[0],
syncJobs: syncJobStats[0],
adminActions: adminStats[0],
};
}
/**
* Get per-user statistics (for admin overview)
* @returns {Promise<Array>} Array of user statistics
*/
export async function getUserStats() {
const stats = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
isAdmin: users.isAdmin,
lastSeen: users.lastSeen,
qsoCount: sql`CAST(COUNT(${qsos.id}) 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)`,
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
lastSync: sql`(
SELECT MAX(${syncJobs.completedAt})
FROM ${syncJobs}
WHERE ${syncJobs.userId} = ${users.id}
AND ${syncJobs.status} = 'completed'
)`.mapWith(Number),
createdAt: users.createdAt,
})
.from(users)
.leftJoin(qsos, eq(users.id, qsos.userId))
.groupBy(users.id)
.orderBy(sql`COUNT(${qsos.id}) DESC`);
// 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 => ({
...stat,
lastSync: stat.lastSync ? new Date(stat.lastSync * 1000) : null,
// lastSeen is already a Date object from Drizzle, don't convert
}));
}
/**
* Impersonate a user
* @param {number} adminId - Admin user ID
* @param {number} targetUserId - Target user ID to impersonate
* @returns {Promise<Object>} Target user object
* @throws {Error} If not admin or trying to impersonate another admin (without super-admin)
*/
export async function impersonateUser(adminId, targetUserId) {
// Verify the requester is an admin
const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) {
throw new Error('Only admins can impersonate users');
}
// Get target user
const targetUser = await getUserByIdFull(targetUserId);
if (!targetUser) {
throw new Error('Target user not found');
}
// Check if target is also an admin
if (targetUser.isAdmin) {
// 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
await logAdminAction(adminId, 'impersonate_start', targetUserId, {
targetEmail: targetUser.email,
targetCallsign: targetUser.callsign,
});
return targetUser;
}
/**
* Verify impersonation token is valid
* @param {Object} impersonationToken - JWT token payload containing impersonation data
* @returns {Promise<Object>} Verification result with target user data
*/
export async function verifyImpersonation(impersonationToken) {
const { adminId, targetUserId, exp } = impersonationToken;
// Check if token is expired
if (Date.now() > exp * 1000) {
throw new Error('Impersonation token has expired');
}
// Verify admin still exists and is admin
const adminUser = await getUserByIdFull(adminId);
if (!adminUser || !adminUser.isAdmin) {
throw new Error('Invalid impersonation: Admin no longer exists or is not admin');
}
// Get target user
const targetUser = await getUserByIdFull(targetUserId);
if (!targetUser) {
throw new Error('Target user not found');
}
// Return target user with admin metadata for frontend display
return {
...targetUser,
impersonating: {
adminId,
adminEmail: adminUser.email,
adminCallsign: adminUser.callsign,
},
};
}
/**
* Stop impersonating a user
* @param {number} adminId - Admin user ID
* @param {number} targetUserId - Target user ID being impersonated
* @returns {Promise<void>}
*/
export async function stopImpersonation(adminId, targetUserId) {
await logAdminAction(adminId, 'impersonate_stop', targetUserId, {
message: 'Impersonation session ended',
});
}
/**
* Get impersonation status for an admin
* @param {number} adminId - Admin user ID
* @param {Object} options - Query options
* @param {number} options.limit - Number of recent impersonations to return
* @returns {Promise<Array>} Array of recent impersonation actions
*/
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
// Use raw SQL for the self-join to avoid Drizzle alias issues
// Using bun:sqlite prepared statements for raw SQL
const query = `
SELECT
aa.id as id,
aa.action_type as actionType,
aa.target_user_id as targetUserId,
u.email as targetEmail,
u.callsign as targetCallsign,
aa.details as details,
aa.created_at as createdAt
FROM admin_actions aa
LEFT JOIN users u ON u.id = aa.target_user_id
WHERE aa.admin_id = ?
AND aa.action_type LIKE 'impersonate%'
ORDER BY aa.created_at DESC
LIMIT ?
`;
return sqlite.prepare(query).all(adminId, limit);
}
/**
* Update user admin status (admin operation)
* @param {number} adminId - Admin user ID making the change
* @param {number} targetUserId - User ID to update
* @param {string} newRole - New role: 'user', 'admin', or 'super-admin'
* @returns {Promise<void>}
* @throws {Error} If not admin or violates security rules
*/
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
const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) {
throw new Error('Only admins can change user roles');
}
// Get requester super-admin status
const requesterIsSuperAdmin = await isSuperAdmin(adminId);
// Get target user
const targetUser = await getUserByIdFull(targetUserId);
if (!targetUser) {
throw new Error('Target user not found');
}
// Security rules for super-admin role changes
const targetWillBeSuperAdmin = newRole === 'super-admin';
const targetIsCurrentlySuperAdmin = targetUser.isSuperAdmin;
// Only super-admins can promote/demote super-admins
if (targetWillBeSuperAdmin || targetIsCurrentlySuperAdmin) {
if (!requesterIsSuperAdmin) {
throw new Error('Only super-admins can promote or demote super-admins');
}
}
// Prevent self-demotion (super-admins cannot demote themselves)
if (adminId === targetUserId) {
if (targetIsCurrentlySuperAdmin && !targetWillBeSuperAdmin) {
throw new Error('Cannot demote yourself from super-admin');
}
}
// 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
await logAdminAction(adminId, 'role_change', targetUserId, {
oldRole: targetUser.isSuperAdmin ? 'super-admin' : (targetUser.isAdmin ? 'admin' : 'user'),
newRole: newRole,
});
}
/**
* Delete user (admin operation)
* @param {number} adminId - Admin user ID making the change
* @param {number} targetUserId - User ID to delete
* @returns {Promise<void>}
* @throws {Error} If not admin, trying to delete self, or trying to delete another admin
*/
export async function deleteUser(adminId, targetUserId) {
// Verify the requester is an admin
const requesterIsAdmin = await isAdmin(adminId);
if (!requesterIsAdmin) {
throw new Error('Only admins can delete users');
}
// Get target user
const targetUser = await getUserByIdFull(targetUserId);
if (!targetUser) {
throw new Error('Target user not found');
}
// Prevent deleting self
if (adminId === targetUserId) {
throw new Error('Cannot delete your own account');
}
// Prevent deleting other admins
if (targetUser.isAdmin) {
throw new Error('Cannot delete admin users');
}
// Get stats for logging
const [qsoStats] = await db
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
.from(qsos)
.where(eq(qsos.userId, targetUserId));
// Delete all related records using Drizzle
// Delete in correct order to satisfy foreign key constraints
logger.info('Attempting to delete user', { userId: targetUserId, adminId });
try {
// 1. Delete qso_changes (references qso_id -> qsos and job_id -> sync_jobs)
// First get user's QSO IDs, then delete qso_changes referencing those QSOs
const userQSOs = await db.select({ id: qsos.id }).from(qsos).where(eq(qsos.userId, targetUserId));
const userQSOIds = userQSOs.map(q => q.id);
if (userQSOIds.length > 0) {
// Use raw SQL to delete qso_changes
sqlite.exec(
`DELETE FROM qso_changes WHERE qso_id IN (${userQSOIds.join(',')})`
);
}
// 2. Delete award_progress
await db.delete(awardProgress).where(eq(awardProgress.userId, targetUserId));
// 3. Delete sync_jobs
await db.delete(syncJobs).where(eq(syncJobs.userId, targetUserId));
// 4. Delete qsos
await db.delete(qsos).where(eq(qsos.userId, targetUserId));
// 5. Delete admin actions where user is target
await db.delete(adminActions).where(eq(adminActions.targetUserId, targetUserId));
// 6. Delete user
await db.delete(users).where(eq(users.id, targetUserId));
// Log action
await logAdminAction(adminId, 'user_delete', targetUserId, {
email: targetUser.email,
callsign: targetUser.callsign,
qsoCountDeleted: qsoStats.count,
});
logger.info('User deleted successfully', { userId: targetUserId, adminId });
} catch (error) {
logger.error('Failed to delete user', { error: error.message, userId: targetUserId });
throw error;
}
// Log action
await logAdminAction(adminId, 'user_delete', targetUserId, {
email: targetUser.email,
callsign: targetUser.callsign,
qsoCountDeleted: qsoStats.count,
});
}

View File

@@ -142,3 +142,133 @@ export async function updateDCLCredentials(userId, dclApiKey) {
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
} }
/**
* Check if user is admin
* @param {number} userId - User ID
* @returns {Promise<boolean>} True if user is admin
*/
export async function isAdmin(userId) {
const [user] = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(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
* @returns {Promise<Array>} Array of admin users (without passwords)
*/
export async function getAdminUsers() {
const adminUsers = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.isAdmin, 1));
return adminUsers;
}
/**
* Update user role
* @param {number} userId - User ID
* @param {string} role - Role: 'user', 'admin', or 'super-admin'
* @returns {Promise<void>}
*/
export async function updateUserRole(userId, role) {
const isAdmin = role === 'admin' || role === 'super-admin';
const isSuperAdmin = role === 'super-admin';
await db
.update(users)
.set({
isAdmin: isAdmin ? 1 : 0,
isSuperAdmin: isSuperAdmin ? 1 : 0,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
}
/**
* Get all users (for admin use)
* @returns {Promise<Array>} Array of all users (without passwords)
*/
export async function getAllUsers() {
const allUsers = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
isAdmin: users.isAdmin,
isSuperAdmin: users.isSuperAdmin,
lastSeen: users.lastSeen,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.orderBy(users.createdAt);
return allUsers;
}
/**
* Get user by ID (for admin use)
* @param {number} userId - User ID
* @returns {Promise<Object|null>} Full user object (without password) or null
*/
export async function getUserByIdFull(userId) {
const [user] = await db
.select({
id: users.id,
email: users.email,
callsign: users.callsign,
isAdmin: users.isAdmin,
isSuperAdmin: users.isSuperAdmin,
lotwUsername: users.lotwUsername,
dclApiKey: users.dclApiKey,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
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,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
*/ */
const awardCache = new Map(); const awardCache = new Map();
const statsCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/** /**
@@ -26,6 +27,7 @@ export function getCachedAwardProgress(userId, awardId) {
const cached = awardCache.get(key); const cached = awardCache.get(key);
if (!cached) { if (!cached) {
recordAwardCacheMiss();
return null; return null;
} }
@@ -33,9 +35,11 @@ export function getCachedAwardProgress(userId, awardId) {
const age = Date.now() - cached.timestamp; const age = Date.now() - cached.timestamp;
if (age > CACHE_TTL) { if (age > CACHE_TTL) {
awardCache.delete(key); awardCache.delete(key);
recordAwardCacheMiss();
return null; return null;
} }
recordAwardCacheHit();
return cached.data; return cached.data;
} }
@@ -82,32 +86,6 @@ export function clearAllCache() {
return size; return size;
} }
/**
* Get cache statistics (for monitoring/debugging)
* @returns {object} Cache stats
*/
export function getCacheStats() {
const now = Date.now();
let expired = 0;
let valid = 0;
for (const [, value] of awardCache) {
const age = now - value.timestamp;
if (age > CACHE_TTL) {
expired++;
} else {
valid++;
}
}
return {
total: awardCache.size,
valid,
expired,
ttl: CACHE_TTL
};
}
/** /**
* Clean up expired cache entries (maintenance function) * Clean up expired cache entries (maintenance function)
* Can be called periodically to free memory * Can be called periodically to free memory
@@ -125,5 +103,147 @@ export function cleanupExpiredCache() {
} }
} }
for (const [key, value] of statsCache) {
const age = now - value.timestamp;
if (age > CACHE_TTL) {
statsCache.delete(key);
cleaned++;
}
}
return cleaned; return cleaned;
} }
/**
* Get cached QSO statistics if available and not expired
* @param {number} userId - User ID
* @returns {object|null} Cached stats data or null if not found/expired
*/
export function getCachedStats(userId) {
const key = `stats_${userId}`;
const cached = statsCache.get(key);
if (!cached) {
recordStatsCacheMiss();
return null;
}
// Check if cache has expired
const age = Date.now() - cached.timestamp;
if (age > CACHE_TTL) {
statsCache.delete(key);
recordStatsCacheMiss();
return null;
}
recordStatsCacheHit();
return cached.data;
}
/**
* Set QSO statistics in cache
* @param {number} userId - User ID
* @param {object} data - Statistics data to cache
*/
export function setCachedStats(userId, data) {
const key = `stats_${userId}`;
statsCache.set(key, {
data,
timestamp: Date.now()
});
}
/**
* Invalidate cached QSO statistics for a specific user
* Call this after syncing or updating QSOs
* @param {number} userId - User ID
* @returns {boolean} True if cache was invalidated
*/
export function invalidateStatsCache(userId) {
const key = `stats_${userId}`;
const deleted = statsCache.delete(key);
return deleted;
}
/**
* Get cache statistics including both award and stats caches
* @returns {object} Cache stats
*/
export function getCacheStats() {
const now = Date.now();
let expired = 0;
let valid = 0;
for (const [, value] of awardCache) {
const age = now - value.timestamp;
if (age > CACHE_TTL) {
expired++;
} else {
valid++;
}
}
for (const [, value] of statsCache) {
const age = now - value.timestamp;
if (age > CACHE_TTL) {
expired++;
} else {
valid++;
}
}
const totalRequests = awardCacheStats.hits + awardCacheStats.misses + statsCacheStats.hits + statsCacheStats.misses;
const hitRate = totalRequests > 0 ? ((awardCacheStats.hits + statsCacheStats.hits) / totalRequests * 100).toFixed(2) + '%' : '0%';
return {
total: awardCache.size + statsCache.size,
valid,
expired,
ttl: CACHE_TTL,
hitRate,
awardCache: {
size: awardCache.size,
hits: awardCacheStats.hits,
misses: awardCacheStats.misses
},
statsCache: {
size: statsCache.size,
hits: statsCacheStats.hits,
misses: statsCacheStats.misses
}
};
}
/**
* Cache statistics tracking
*/
const awardCacheStats = { hits: 0, misses: 0 };
const statsCacheStats = { hits: 0, misses: 0 };
/**
* Record a cache hit for awards
*/
export function recordAwardCacheHit() {
awardCacheStats.hits++;
}
/**
* Record a cache miss for awards
*/
export function recordAwardCacheMiss() {
awardCacheStats.misses++;
}
/**
* Record a cache hit for stats
*/
export function recordStatsCacheHit() {
statsCacheStats.hits++;
}
/**
* Record a cache miss for stats
*/
export function recordStatsCacheMiss() {
statsCacheStats.misses++;
}

View File

@@ -3,7 +3,8 @@ import { qsos, qsoChanges } from '../db/schema/index.js';
import { max, sql, eq, and, desc } from 'drizzle-orm'; import { max, sql, eq, and, desc } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js'; import { updateJobProgress } from './job-queue.service.js';
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js'; import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
import { invalidateUserCache } from './cache.service.js'; import { invalidateUserCache, invalidateStatsCache } from './cache.service.js';
import { yieldToEventLoop, getQSOKey } from '../utils/sync-helpers.js';
/** /**
* DCL (DARC Community Logbook) Service * DCL (DARC Community Logbook) Service
@@ -122,17 +123,6 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
} }
} }
/**
* Parse DCL API response from JSON
* Can be used for testing with example payloads
*
* @param {Object} jsonResponse - JSON response in DCL format
* @returns {Array} Array of parsed QSO records
*/
export function parseDCLJSONResponse(jsonResponse) {
return parseDCLResponse(jsonResponse);
}
/** /**
* Convert DCL ADIF QSO to database format * Convert DCL ADIF QSO to database format
* @param {Object} adifQSO - Parsed ADIF QSO record * @param {Object} adifQSO - Parsed ADIF QSO record
@@ -170,7 +160,7 @@ function convertQSODatabaseFormat(adifQSO, userId) {
} }
/** /**
* Sync QSOs from DCL to database * Sync QSOs from DCL to database (optimized with batch operations)
* Updates existing QSOs with DCL confirmation data * Updates existing QSOs with DCL confirmation data
* *
* @param {number} userId - User ID * @param {number} userId - User ID
@@ -219,31 +209,52 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
const addedQSOs = []; const addedQSOs = [];
const updatedQSOs = []; const updatedQSOs = [];
for (let i = 0; i < adifQSOs.length; i++) { // Convert all QSOs to database format
const adifQSO = adifQSOs[i]; const dbQSOs = adifQSOs.map(qso => convertQSODatabaseFormat(qso, userId));
try { // Batch size for processing
const dbQSO = convertQSODatabaseFormat(adifQSO, userId); const BATCH_SIZE = 100;
const totalBatches = Math.ceil(dbQSOs.length / BATCH_SIZE);
// Check if QSO already exists (match by callsign, date, time, band, mode) for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
const existing = await db const startIdx = batchNum * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
const batch = dbQSOs.slice(startIdx, endIdx);
// Get unique callsigns and dates from batch
const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
const batchDates = [...new Set(batch.map(q => q.qsoDate))];
// Fetch all existing QSOs that could match this batch in one query
const existingQSOs = await db
.select() .select()
.from(qsos) .from(qsos)
.where( .where(
and( and(
eq(qsos.userId, userId), eq(qsos.userId, userId),
eq(qsos.callsign, dbQSO.callsign), // Match callsigns OR dates from this batch
eq(qsos.qsoDate, dbQSO.qsoDate), sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
eq(qsos.timeOn, dbQSO.timeOn),
eq(qsos.band, dbQSO.band),
eq(qsos.mode, dbQSO.mode)
) )
) );
.limit(1);
if (existing.length > 0) { // Build lookup map for existing QSOs
const existingQSO = existing[0]; const existingMap = new Map();
for (const existing of existingQSOs) {
const key = getQSOKey(existing);
existingMap.set(key, existing);
}
// Process batch
const toInsert = [];
const toUpdate = [];
const changeRecords = [];
for (const dbQSO of batch) {
try {
const key = getQSOKey(dbQSO);
const existingQSO = existingMap.get(key);
if (existingQSO) {
// Check if DCL confirmation or DOK data has changed // Check if DCL confirmation or DOK data has changed
const dataChanged = const dataChanged =
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus || existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
@@ -253,19 +264,7 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
existingQSO.grid !== (dbQSO.grid || existingQSO.grid); existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
if (dataChanged) { if (dataChanged) {
// Record before state for rollback // Build update data
const beforeData = JSON.stringify({
dclQslRstatus: existingQSO.dclQslRstatus,
dclQslRdate: existingQSO.dclQslRdate,
darcDok: existingQSO.darcDok,
myDarcDok: existingQSO.myDarcDok,
grid: existingQSO.grid,
gridSource: existingQSO.gridSource,
entity: existingQSO.entity,
entityId: existingQSO.entityId,
});
// Update existing QSO with changed DCL confirmation and DOK data
const updateData = { const updateData = {
dclQslRdate: dbQSO.dclQslRdate, dclQslRdate: dbQSO.dclQslRdate,
dclQslRstatus: dbQSO.dclQslRstatus, dclQslRstatus: dbQSO.dclQslRstatus,
@@ -291,7 +290,6 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
const missingEntity = !existingQSO.entity || existingQSO.entity === ''; const missingEntity = !existingQSO.entity || existingQSO.entity === '';
if (!hasLoTWConfirmation && hasDCLData && missingEntity) { if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
// Fill in entity data from DCL (only if DCL provides it)
if (dbQSO.entity) updateData.entity = dbQSO.entity; if (dbQSO.entity) updateData.entity = dbQSO.entity;
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId; if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
if (dbQSO.continent) updateData.continent = dbQSO.continent; if (dbQSO.continent) updateData.continent = dbQSO.continent;
@@ -299,13 +297,28 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone; if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
} }
await db toUpdate.push({
.update(qsos) id: existingQSO.id,
.set(updateData) data: updateData,
.where(eq(qsos.id, existingQSO.id)); });
// Record after state for rollback // Track change for rollback
const afterData = JSON.stringify({ if (jobId) {
changeRecords.push({
jobId,
qsoId: existingQSO.id,
changeType: 'updated',
beforeData: JSON.stringify({
dclQslRstatus: existingQSO.dclQslRstatus,
dclQslRdate: existingQSO.dclQslRdate,
darcDok: existingQSO.darcDok,
myDarcDok: existingQSO.myDarcDok,
grid: existingQSO.grid,
gridSource: existingQSO.gridSource,
entity: existingQSO.entity,
entityId: existingQSO.entityId,
}),
afterData: JSON.stringify({
dclQslRstatus: dbQSO.dclQslRstatus, dclQslRstatus: dbQSO.dclQslRstatus,
dclQslRdate: dbQSO.dclQslRdate, dclQslRdate: dbQSO.dclQslRdate,
darcDok: updateData.darcDok, darcDok: updateData.darcDok,
@@ -314,21 +327,10 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
gridSource: updateData.gridSource, gridSource: updateData.gridSource,
entity: updateData.entity, entity: updateData.entity,
entityId: updateData.entityId, entityId: updateData.entityId,
}); }),
// Track change in qso_changes table if jobId provided
if (jobId) {
await db.insert(qsoChanges).values({
jobId,
qsoId: existingQSO.id,
changeType: 'updated',
beforeData,
afterData,
}); });
} }
updatedCount++;
// Track updated QSO (CALL and DATE)
updatedQSOs.push({ updatedQSOs.push({
id: existingQSO.id, id: existingQSO.id,
callsign: dbQSO.callsign, callsign: dbQSO.callsign,
@@ -336,64 +338,86 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
band: dbQSO.band, band: dbQSO.band,
mode: dbQSO.mode, mode: dbQSO.mode,
}); });
updatedCount++;
} else { } else {
// Skip - same data
skippedCount++; skippedCount++;
} }
} else { } else {
// Insert new QSO // New QSO to insert
const [newQSO] = await db.insert(qsos).values(dbQSO).returning(); toInsert.push(dbQSO);
// Track change in qso_changes table if jobId provided
if (jobId) {
const afterData = JSON.stringify({
callsign: dbQSO.callsign,
qsoDate: dbQSO.qsoDate,
timeOn: dbQSO.timeOn,
band: dbQSO.band,
mode: dbQSO.mode,
});
await db.insert(qsoChanges).values({
jobId,
qsoId: newQSO.id,
changeType: 'added',
beforeData: null,
afterData,
});
}
addedCount++;
// Track added QSO (CALL and DATE)
addedQSOs.push({ addedQSOs.push({
id: newQSO.id,
callsign: dbQSO.callsign, callsign: dbQSO.callsign,
date: dbQSO.qsoDate, date: dbQSO.qsoDate,
band: dbQSO.band, band: dbQSO.band,
mode: dbQSO.mode, mode: dbQSO.mode,
}); });
} addedCount++;
// Update job progress every 10 QSOs
if (jobId && (i + 1) % 10 === 0) {
await updateJobProgress(jobId, {
processed: i + 1,
message: `Processed ${i + 1}/${adifQSOs.length} QSOs from DCL...`,
});
} }
} catch (error) { } catch (error) {
logger.error('Failed to process DCL QSO', { logger.error('Failed to process DCL QSO in batch', {
error: error.message, error: error.message,
qso: adifQSO, qso: dbQSO,
userId, userId,
}); });
errors.push({ qso: adifQSO, error: error.message }); errors.push({ qso: dbQSO, error: error.message });
} }
} }
// Batch insert new QSOs
if (toInsert.length > 0) {
const inserted = await db.insert(qsos).values(toInsert).returning();
// Track inserted QSOs with their IDs for change tracking
if (jobId) {
for (let i = 0; i < inserted.length; i++) {
changeRecords.push({
jobId,
qsoId: inserted[i].id,
changeType: 'added',
beforeData: null,
afterData: JSON.stringify({
callsign: toInsert[i].callsign,
qsoDate: toInsert[i].qsoDate,
timeOn: toInsert[i].timeOn,
band: toInsert[i].band,
mode: toInsert[i].mode,
}),
});
// Update addedQSOs with actual IDs
addedQSOs[addedCount - inserted.length + i].id = inserted[i].id;
}
}
}
// Batch update existing QSOs
if (toUpdate.length > 0) {
for (const update of toUpdate) {
await db
.update(qsos)
.set(update.data)
.where(eq(qsos.id, update.id));
}
}
// Batch insert change records
if (changeRecords.length > 0) {
await db.insert(qsoChanges).values(changeRecords);
}
// Update job progress after each batch
if (jobId) {
await updateJobProgress(jobId, {
processed: endIdx,
message: `Processed ${endIdx}/${dbQSOs.length} QSOs from DCL...`,
});
}
// Yield to event loop after each batch to allow other requests
await yieldToEventLoop();
}
const result = { const result = {
success: true, success: true,
total: adifQSOs.length, total: dbQSOs.length,
added: addedCount, added: addedCount,
updated: updatedCount, updated: updatedCount,
skipped: skippedCount, skipped: skippedCount,
@@ -411,7 +435,8 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
// Invalidate award cache for this user since QSOs may have changed // Invalidate award cache for this user since QSOs may have changed
const deletedCache = invalidateUserCache(userId); const deletedCache = invalidateUserCache(userId);
logger.debug(`Invalidated ${deletedCache} cached award entries for user ${userId}`); invalidateStatsCache(userId);
logger.debug(`Invalidated ${deletedCache} cached award entries and stats cache for user ${userId}`);
return result; return result;

View File

@@ -1,9 +1,11 @@
import { db, logger } from '../config.js'; import { db, logger } from '../config.js';
import { qsos, qsoChanges } from '../db/schema/index.js'; import { qsos, qsoChanges, syncJobs, awardProgress } from '../db/schema/index.js';
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm'; import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
import { updateJobProgress } from './job-queue.service.js'; import { updateJobProgress } from './job-queue.service.js';
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js'; import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
import { invalidateUserCache } from './cache.service.js'; import { invalidateUserCache, getCachedStats, setCachedStats, invalidateStatsCache } from './cache.service.js';
import { trackQueryPerformance } from './performance.service.js';
import { yieldToEventLoop, getQSOKey } from '../utils/sync-helpers.js';
/** /**
* LoTW (Logbook of the World) Service * LoTW (Logbook of the World) Service
@@ -15,6 +17,35 @@ const MAX_RETRIES = 30;
const RETRY_DELAY = 10000; const RETRY_DELAY = 10000;
const REQUEST_TIMEOUT = 60000; const REQUEST_TIMEOUT = 60000;
/**
* SECURITY: Sanitize search input to prevent injection and DoS
* Limits length and removes potentially harmful characters
*/
function sanitizeSearchInput(searchTerm) {
if (!searchTerm || typeof searchTerm !== 'string') {
return '';
}
// Trim whitespace
let sanitized = searchTerm.trim();
// Limit length (DoS prevention)
const MAX_SEARCH_LENGTH = 100;
if (sanitized.length > MAX_SEARCH_LENGTH) {
sanitized = sanitized.substring(0, MAX_SEARCH_LENGTH);
}
// Remove potentially dangerous SQL pattern wildcards from user input
// We'll add our own wildcards for the LIKE query
// Note: Drizzle ORM escapes parameters, but this adds defense-in-depth
sanitized = sanitized.replace(/[%_\\]/g, '');
// Remove null bytes and other control characters
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, '');
return sanitized;
}
/** /**
* Check if LoTW response indicates the report is still being prepared * Check if LoTW response indicates the report is still being prepared
*/ */
@@ -51,6 +82,7 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
* Fetch QSOs from LoTW with retry support * Fetch QSOs from LoTW with retry support
*/ */
async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
const startTime = Date.now();
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi'; const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -146,7 +178,7 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
} }
} }
const totalTime = Math.round((Date.now() - Date.now()) / 1000); const totalTime = Math.round((Date.now() - startTime) / 1000);
return { return {
error: `LoTW sync failed: Report not ready after ${MAX_RETRIES} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.` error: `LoTW sync failed: Report not ready after ${MAX_RETRIES} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`
}; };
@@ -181,7 +213,7 @@ function convertQSODatabaseFormat(adifQSO, userId) {
} }
/** /**
* Sync QSOs from LoTW to database * Sync QSOs from LoTW to database (optimized with batch operations)
* @param {number} userId - User ID * @param {number} userId - User ID
* @param {string} lotwUsername - LoTW username * @param {string} lotwUsername - LoTW username
* @param {string} lotwPassword - LoTW password * @param {string} lotwPassword - LoTW password
@@ -228,70 +260,83 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
const addedQSOs = []; const addedQSOs = [];
const updatedQSOs = []; const updatedQSOs = [];
for (let i = 0; i < adifQSOs.length; i++) { // Convert all QSOs to database format
const qsoData = adifQSOs[i]; const dbQSOs = adifQSOs.map(qsoData => convertQSODatabaseFormat(qsoData, userId));
try { // Batch size for processing
const dbQSO = convertQSODatabaseFormat(qsoData, userId); const BATCH_SIZE = 100;
const totalBatches = Math.ceil(dbQSOs.length / BATCH_SIZE);
const existing = await db for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
const startIdx = batchNum * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
const batch = dbQSOs.slice(startIdx, endIdx);
// Build condition for batch duplicate check
// Get unique callsigns, dates, bands, modes from batch
const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
const batchDates = [...new Set(batch.map(q => q.qsoDate))];
// Fetch all existing QSOs that could match this batch in one query
const existingQSOs = await db
.select() .select()
.from(qsos) .from(qsos)
.where( .where(
and( and(
eq(qsos.userId, userId), eq(qsos.userId, userId),
eq(qsos.callsign, dbQSO.callsign), // Match callsigns OR dates from this batch
eq(qsos.qsoDate, dbQSO.qsoDate), sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
eq(qsos.timeOn, dbQSO.timeOn),
eq(qsos.band, dbQSO.band),
eq(qsos.mode, dbQSO.mode)
) )
) );
.limit(1);
if (existing.length > 0) { // Build lookup map for existing QSOs
const existingQSO = existing[0]; const existingMap = new Map();
for (const existing of existingQSOs) {
const key = getQSOKey(existing);
existingMap.set(key, existing);
}
// Process batch
const toInsert = [];
const toUpdate = [];
const changeRecords = [];
for (const dbQSO of batch) {
try {
const key = getQSOKey(dbQSO);
const existingQSO = existingMap.get(key);
if (existingQSO) {
// Check if LoTW confirmation data has changed // Check if LoTW confirmation data has changed
const confirmationChanged = const confirmationChanged =
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus || existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus ||
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate; existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate;
if (confirmationChanged) { if (confirmationChanged) {
// Record before state for rollback toUpdate.push({
const beforeData = JSON.stringify({ id: existingQSO.id,
lotwQslRstatus: existingQSO.lotwQslRstatus,
lotwQslRdate: existingQSO.lotwQslRdate,
});
await db
.update(qsos)
.set({
lotwQslRdate: dbQSO.lotwQslRdate, lotwQslRdate: dbQSO.lotwQslRdate,
lotwQslRstatus: dbQSO.lotwQslRstatus, lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwSyncedAt: dbQSO.lotwSyncedAt, lotwSyncedAt: dbQSO.lotwSyncedAt,
})
.where(eq(qsos.id, existingQSO.id));
// Record after state for rollback
const afterData = JSON.stringify({
lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwQslRdate: dbQSO.lotwQslRdate,
}); });
// Track change in qso_changes table if jobId provided // Track change for rollback
if (jobId) { if (jobId) {
await db.insert(qsoChanges).values({ changeRecords.push({
jobId, jobId,
qsoId: existingQSO.id, qsoId: existingQSO.id,
changeType: 'updated', changeType: 'updated',
beforeData, beforeData: JSON.stringify({
afterData, lotwQslRstatus: existingQSO.lotwQslRstatus,
lotwQslRdate: existingQSO.lotwQslRdate,
}),
afterData: JSON.stringify({
lotwQslRstatus: dbQSO.lotwQslRstatus,
lotwQslRdate: dbQSO.lotwQslRdate,
}),
}); });
} }
updatedCount++;
// Track updated QSO (CALL and DATE)
updatedQSOs.push({ updatedQSOs.push({
id: existingQSO.id, id: existingQSO.id,
callsign: dbQSO.callsign, callsign: dbQSO.callsign,
@@ -299,66 +344,93 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
band: dbQSO.band, band: dbQSO.band,
mode: dbQSO.mode, mode: dbQSO.mode,
}); });
updatedCount++;
} else { } else {
// Skip - same data
skippedCount++; skippedCount++;
} }
} else { } else {
// Insert new QSO // New QSO to insert
const [newQSO] = await db.insert(qsos).values(dbQSO).returning(); toInsert.push(dbQSO);
// Track change in qso_changes table if jobId provided
if (jobId) {
const afterData = JSON.stringify({
callsign: dbQSO.callsign,
qsoDate: dbQSO.qsoDate,
timeOn: dbQSO.timeOn,
band: dbQSO.band,
mode: dbQSO.mode,
});
await db.insert(qsoChanges).values({
jobId,
qsoId: newQSO.id,
changeType: 'added',
beforeData: null,
afterData,
});
}
addedCount++;
// Track added QSO (CALL and DATE)
addedQSOs.push({ addedQSOs.push({
id: newQSO.id,
callsign: dbQSO.callsign, callsign: dbQSO.callsign,
date: dbQSO.qsoDate, date: dbQSO.qsoDate,
band: dbQSO.band, band: dbQSO.band,
mode: dbQSO.mode, mode: dbQSO.mode,
}); });
} addedCount++;
// Update job progress every 10 QSOs
if (jobId && (i + 1) % 10 === 0) {
await updateJobProgress(jobId, {
processed: i + 1,
message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`,
});
} }
} catch (error) { } catch (error) {
logger.error('Error processing QSO', { error: error.message, jobId, qso: qsoData }); logger.error('Error processing QSO in batch', { error: error.message, jobId, qso: dbQSO });
errors.push({ qso: qsoData, error: error.message }); errors.push({ qso: dbQSO, error: error.message });
} }
} }
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId }); // Batch insert new QSOs
if (toInsert.length > 0) {
const inserted = await db.insert(qsos).values(toInsert).returning();
// Track inserted QSOs with their IDs for change tracking
if (jobId) {
for (let i = 0; i < inserted.length; i++) {
changeRecords.push({
jobId,
qsoId: inserted[i].id,
changeType: 'added',
beforeData: null,
afterData: JSON.stringify({
callsign: toInsert[i].callsign,
qsoDate: toInsert[i].qsoDate,
timeOn: toInsert[i].timeOn,
band: toInsert[i].band,
mode: toInsert[i].mode,
}),
});
// Update addedQSOs with actual IDs
addedQSOs[addedCount - inserted.length + i].id = inserted[i].id;
}
}
}
// Invalidate award cache for this user since QSOs may have changed // Batch update existing QSOs
if (toUpdate.length > 0) {
for (const update of toUpdate) {
await db
.update(qsos)
.set({
lotwQslRdate: update.lotwQslRdate,
lotwQslRstatus: update.lotwQslRstatus,
lotwSyncedAt: update.lotwSyncedAt,
})
.where(eq(qsos.id, update.id));
}
}
// Batch insert change records
if (changeRecords.length > 0) {
await db.insert(qsoChanges).values(changeRecords);
}
// Update job progress after each batch
if (jobId) {
await updateJobProgress(jobId, {
processed: endIdx,
message: `Processed ${endIdx}/${dbQSOs.length} QSOs...`,
});
}
// Yield to event loop after each batch to allow other requests
await yieldToEventLoop();
}
logger.info('LoTW sync completed', { total: dbQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
// Invalidate award and stats cache for this user since QSOs may have changed
const deletedCache = invalidateUserCache(userId); const deletedCache = invalidateUserCache(userId);
logger.debug(`Invalidated ${deletedCache} cached award entries for user ${userId}`); invalidateStatsCache(userId);
logger.debug(`Invalidated ${deletedCache} cached award entries and stats cache for user ${userId}`);
return { return {
success: true, success: true,
total: adifQSOs.length, total: dbQSOs.length,
added: addedCount, added: addedCount,
updated: updatedCount, updated: updatedCount,
skipped: skippedCount, skipped: skippedCount,
@@ -419,13 +491,17 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
// Search filter: callsign, entity, or grid // Search filter: callsign, entity, or grid
if (filters.search) { if (filters.search) {
const searchTerm = `%${filters.search}%`; // SECURITY: Sanitize search input to prevent injection
const sanitized = sanitizeSearchInput(filters.search);
if (sanitized) {
const searchTerm = `%${sanitized}%`;
conditions.push(or( conditions.push(or(
like(qsos.callsign, searchTerm), like(qsos.callsign, searchTerm),
like(qsos.entity, searchTerm), like(qsos.entity, searchTerm),
like(qsos.grid, searchTerm) like(qsos.grid, searchTerm)
)); ));
} }
}
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory) // Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
const [{ count }] = await db const [{ count }] = await db
@@ -461,26 +537,40 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
* Get QSO statistics for a user * Get QSO statistics for a user
*/ */
export async function getQSOStats(userId) { export async function getQSOStats(userId) {
const allQSOs = await db.select().from(qsos).where(eq(qsos.userId, userId)); // Check cache first
const confirmed = allQSOs.filter((q) => q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y'); const cached = getCachedStats(userId);
if (cached) {
return cached;
}
const uniqueEntities = new Set(); // Calculate stats from database with performance tracking
const uniqueBands = new Set(); const stats = await trackQueryPerformance('getQSOStats', async () => {
const uniqueModes = new Set(); const [basicStats, uniqueStats] = await Promise.all([
db.select({
total: sql`CAST(COUNT(*) AS INTEGER)`,
confirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' OR dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`
}).from(qsos).where(eq(qsos.userId, userId)),
allQSOs.forEach((q) => { db.select({
if (q.entity) uniqueEntities.add(q.entity); uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
if (q.band) uniqueBands.add(q.band); uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
if (q.mode) uniqueModes.add(q.mode); uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
}); }).from(qsos).where(eq(qsos.userId, userId))
]);
return { return {
total: allQSOs.length, total: basicStats[0].total,
confirmed: confirmed.length, confirmed: basicStats[0].confirmed || 0,
uniqueEntities: uniqueEntities.size, uniqueEntities: uniqueStats[0].uniqueEntities || 0,
uniqueBands: uniqueBands.size, uniqueBands: uniqueStats[0].uniqueBands || 0,
uniqueModes: uniqueModes.size, uniqueModes: uniqueStats[0].uniqueModes || 0,
}; };
});
// Cache results
setCachedStats(userId, stats);
return stats;
} }
/** /**
@@ -506,10 +596,58 @@ export async function getLastLoTWQSLDate(userId) {
/** /**
* Delete all QSOs for a user * Delete all QSOs for a user
* Also deletes related qso_changes records to satisfy foreign key constraints
*/ */
export async function deleteQSOs(userId) { export async function deleteQSOs(userId) {
logger.debug('Deleting all QSOs for user', { userId });
// Step 1: Delete qso_changes that reference QSOs for this user
// Need to use a subquery since qso_changes doesn't have userId directly
const qsoIdsResult = await db
.select({ id: qsos.id })
.from(qsos)
.where(eq(qsos.userId, userId));
const qsoIds = qsoIdsResult.map(r => r.id);
let deletedChanges = 0;
if (qsoIds.length > 0) {
// Delete qso_changes where qsoId is in the list of QSO IDs
const changesResult = await db
.delete(qsoChanges)
.where(sql`${qsoChanges.qsoId} IN ${sql.raw(`(${qsoIds.join(',')})`)}`);
deletedChanges = changesResult.changes || changesResult || 0;
logger.debug('Deleted qso_changes', { count: deletedChanges });
}
// Step 2: Delete the QSOs
const result = await db.delete(qsos).where(eq(qsos.userId, userId)); const result = await db.delete(qsos).where(eq(qsos.userId, userId));
return result; logger.debug('Delete result', { result, type: typeof result, keys: Object.keys(result || {}) });
// Drizzle with SQLite/bun:sqlite returns various formats depending on driver
let count = 0;
if (result) {
if (typeof result === 'number') {
count = result;
} else if (result.changes !== undefined) {
count = result.changes;
} else if (result.rows !== undefined) {
count = result.rows;
} else if (result.meta?.changes !== undefined) {
count = result.meta.changes;
} else if (result.meta?.rows !== undefined) {
count = result.meta.rows;
}
}
logger.info('Deleted QSOs', { userId, count, deletedChanges });
// Invalidate caches for this user
await invalidateStatsCache(userId);
await invalidateUserCache(userId);
return count;
} }
/** /**

View File

@@ -0,0 +1,274 @@
/**
* Performance Monitoring Service
*
* Tracks query performance metrics to identify slow queries and detect regressions.
*
* Features:
* - Track individual query performance
* - Calculate averages and percentiles
* - Detect slow queries automatically
* - Provide performance statistics for monitoring
*
* Usage:
* const result = await trackQueryPerformance('getQSOStats', async () => {
* return await someExpensiveOperation();
* });
*/
// Performance metrics storage
const queryMetrics = new Map();
// Thresholds for slow queries
const SLOW_QUERY_THRESHOLD = 100; // 100ms = slow
const CRITICAL_QUERY_THRESHOLD = 500; // 500ms = critical
/**
* Track query performance and log results
* @param {string} queryName - Name of the query/operation
* @param {Function} fn - Async function to execute and track
* @returns {Promise<any>} Result of the function
*/
export async function trackQueryPerformance(queryName, fn) {
const start = performance.now();
let result;
let error = null;
try {
result = await fn();
} catch (err) {
error = err;
throw err; // Re-throw error
} finally {
const duration = performance.now() - start;
recordQueryMetric(queryName, duration, error);
// Log slow queries
if (duration > CRITICAL_QUERY_THRESHOLD) {
console.error(`🚨 CRITICAL SLOW QUERY: ${queryName} took ${duration.toFixed(2)}ms`);
} else if (duration > SLOW_QUERY_THRESHOLD) {
console.warn(`⚠️ SLOW QUERY: ${queryName} took ${duration.toFixed(2)}ms`);
} else {
console.log(`✅ Query Performance: ${queryName} - ${duration.toFixed(2)}ms`);
}
}
return result;
}
/**
* Record a query metric for later analysis
* @param {string} queryName - Name of the query
* @param {number} duration - Query duration in milliseconds
* @param {Error|null} error - Error if query failed
*/
function recordQueryMetric(queryName, duration, error = null) {
if (!queryMetrics.has(queryName)) {
queryMetrics.set(queryName, {
count: 0,
totalTime: 0,
minTime: Infinity,
maxTime: 0,
errors: 0,
durations: [] // Keep recent durations for percentile calculation
});
}
const metrics = queryMetrics.get(queryName);
metrics.count++;
metrics.totalTime += duration;
metrics.minTime = Math.min(metrics.minTime, duration);
metrics.maxTime = Math.max(metrics.maxTime, duration);
if (error) metrics.errors++;
// Keep last 100 durations for percentile calculation
metrics.durations.push(duration);
if (metrics.durations.length > 100) {
metrics.durations.shift();
}
}
/**
* Get performance statistics for a specific query or all queries
* @param {string|null} queryName - Query name or null for all queries
* @returns {object} Performance statistics
*/
export function getPerformanceStats(queryName = null) {
if (queryName) {
const metrics = queryMetrics.get(queryName);
if (!metrics) {
return null;
}
return calculateQueryStats(queryName, metrics);
}
// Get stats for all queries
const stats = {};
for (const [name, metrics] of queryMetrics.entries()) {
stats[name] = calculateQueryStats(name, metrics);
}
return stats;
}
/**
* Calculate statistics for a query
* @param {string} queryName - Name of the query
* @param {object} metrics - Raw metrics
* @returns {object} Calculated statistics
*/
function calculateQueryStats(queryName, metrics) {
const avgTime = metrics.totalTime / metrics.count;
// Calculate percentiles (P50, P95, P99)
const sorted = [...metrics.durations].sort((a, b) => a - b);
const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0;
const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;
const p99 = sorted[Math.floor(sorted.length * 0.99)] || 0;
// Determine performance rating
let rating = 'EXCELLENT';
if (avgTime > CRITICAL_QUERY_THRESHOLD) {
rating = 'CRITICAL';
} else if (avgTime > SLOW_QUERY_THRESHOLD) {
rating = 'SLOW';
} else if (avgTime > 50) {
rating = 'GOOD';
}
return {
name: queryName,
count: metrics.count,
avgTime: avgTime.toFixed(2) + 'ms',
minTime: metrics.minTime.toFixed(2) + 'ms',
maxTime: metrics.maxTime.toFixed(2) + 'ms',
p50: p50.toFixed(2) + 'ms',
p95: p95.toFixed(2) + 'ms',
p99: p99.toFixed(2) + 'ms',
errors: metrics.errors,
errorRate: ((metrics.errors / metrics.count) * 100).toFixed(2) + '%',
rating
};
}
/**
* Get overall performance summary
* @returns {object} Summary of all query performance
*/
export function getPerformanceSummary() {
if (queryMetrics.size === 0) {
return {
totalQueries: 0,
totalTime: 0,
avgTime: '0ms',
slowQueries: 0,
criticalQueries: 0,
topSlowest: []
};
}
let totalQueries = 0;
let totalTime = 0;
let slowQueries = 0;
let criticalQueries = 0;
const allStats = [];
for (const [name, metrics] of queryMetrics.entries()) {
const stats = calculateQueryStats(name, metrics);
totalQueries += metrics.count;
totalTime += metrics.totalTime;
const avgTime = metrics.totalTime / metrics.count;
if (avgTime > CRITICAL_QUERY_THRESHOLD) {
criticalQueries++;
} else if (avgTime > SLOW_QUERY_THRESHOLD) {
slowQueries++;
}
allStats.push(stats);
}
// Sort by average time (slowest first)
const topSlowest = allStats
.sort((a, b) => parseFloat(b.avgTime) - parseFloat(a.avgTime))
.slice(0, 10);
return {
totalQueries,
totalTime: totalTime.toFixed(2) + 'ms',
avgTime: (totalTime / totalQueries).toFixed(2) + 'ms',
slowQueries,
criticalQueries,
topSlowest
};
}
/**
* Reset performance metrics (for testing)
*/
export function resetPerformanceMetrics() {
queryMetrics.clear();
console.log('Performance metrics cleared');
}
/**
* Get slow queries (above threshold)
* @param {number} threshold - Duration threshold in ms (default: 100ms)
* @returns {Array} Array of slow query statistics
*/
export function getSlowQueries(threshold = SLOW_QUERY_THRESHOLD) {
const slowQueries = [];
for (const [name, metrics] of queryMetrics.entries()) {
const avgTime = metrics.totalTime / metrics.count;
if (avgTime > threshold) {
slowQueries.push(calculateQueryStats(name, metrics));
}
}
// Sort by average time (slowest first)
return slowQueries.sort((a, b) => parseFloat(b.avgTime) - parseFloat(a.avgTime));
}
/**
* Performance monitoring utility for database queries
* @param {string} queryName - Name of the query
* @param {Function} queryFn - Query function to track
* @returns {Promise<any>} Query result
*/
export async function trackQuery(queryName, queryFn) {
return trackQueryPerformance(queryName, queryFn);
}
/**
* Check if performance is degrading (compares recent vs overall average)
* @param {string} queryName - Query name to check
* @param {number} windowSize - Number of recent queries to compare (default: 10)
* @returns {object} Degradation status
*/
export function checkPerformanceDegradation(queryName, windowSize = 10) {
const metrics = queryMetrics.get(queryName);
if (!metrics || metrics.durations.length < windowSize * 2) {
return {
degraded: false,
message: 'Insufficient data'
};
}
// Recent queries (last N)
const recentDurations = metrics.durations.slice(-windowSize);
const avgRecent = recentDurations.reduce((a, b) => a + b, 0) / recentDurations.length;
// Overall average
const avgOverall = metrics.totalTime / metrics.count;
// Check if recent is 2x worse than overall
const degraded = avgRecent > avgOverall * 2;
const change = ((avgRecent - avgOverall) / avgOverall * 100).toFixed(2) + '%';
return {
degraded,
avgRecent: avgRecent.toFixed(2) + 'ms',
avgOverall: avgOverall.toFixed(2) + 'ms',
change,
message: degraded ? `Performance degraded by ${change}` : 'Performance stable'
};
}

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();
}

View File

@@ -0,0 +1,23 @@
/**
* Sync Helper Utilities
*
* Shared utilities for LoTW and DCL sync operations
*/
/**
* Yield to event loop to allow other requests to be processed
* This prevents blocking the server during long-running sync operations
* @returns {Promise<void>}
*/
export function yieldToEventLoop() {
return new Promise(resolve => setImmediate(resolve));
}
/**
* Get QSO key for duplicate detection
* @param {object} qso - QSO object
* @returns {string} Unique key for the QSO
*/
export function getQSOKey(qso) {
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
}

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

@@ -86,3 +86,73 @@ export const jobsAPI = {
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`), getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
cancel: (jobId) => apiRequest(`/jobs/${jobId}`, { method: 'DELETE' }), cancel: (jobId) => apiRequest(`/jobs/${jobId}`, { method: 'DELETE' }),
}; };
// Admin API
export const adminAPI = {
getStats: () => apiRequest('/admin/stats'),
getUsers: () => apiRequest('/admin/users'),
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
updateUserRole: (userId, role) => apiRequest(`/admin/users/${userId}/role`, {
method: 'POST',
body: JSON.stringify({ role }),
}),
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
method: 'DELETE',
}),
impersonate: (userId) => apiRequest(`/admin/impersonate/${userId}`, {
method: 'POST',
}),
stopImpersonation: () => apiRequest('/admin/impersonate/stop', {
method: 'POST',
}),
getImpersonationStatus: () => apiRequest('/admin/impersonation/status'),
getActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions?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

@@ -103,6 +103,15 @@ function createAuthStore() {
clearError: () => { clearError: () => {
update((s) => ({ ...s, error: null })); update((s) => ({ ...s, error: null }));
}, },
// Direct login with user object and token (for impersonation)
loginWithToken: (user, token) => {
if (browser) {
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
}
set({ user, token, loading: false, error: null });
},
}; };
} }

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,52 @@
<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 ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import '../app.css';
let stoppingImpersonation = false;
// Initialize theme on mount
onMount(() => {
theme.init();
});
function handleLogout() { function handleLogout() {
auth.logout(); auth.logout();
goto('/auth/login'); // Use hard redirect to ensure proper navigation after logout
// goto() may not work properly due to SvelteKit client-side routing
if (browser) {
window.location.href = '/auth/login';
}
}
async function handleStopImpersonation() {
if (stoppingImpersonation) return;
try {
stoppingImpersonation = true;
const data = await adminAPI.stopImpersonation();
if (data.success) {
// Update auth store with admin user data and new token
auth.loginWithToken(data.user, data.token);
// Hard redirect to home page
if (browser) {
window.location.href = '/';
}
} else {
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Failed to stop impersonation: ' + err.message);
} finally {
stoppingImpersonation = false;
}
} }
</script> </script>
@@ -27,11 +68,35 @@
<a href="/awards" class="nav-link">Awards</a> <a href="/awards" class="nav-link">Awards</a>
<a href="/qsos" class="nav-link">QSOs</a> <a href="/qsos" class="nav-link">QSOs</a>
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
{#if $auth.user?.isAdmin}
<a href="/admin" class="nav-link admin-link">Admin</a>
{/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>
</nav> </nav>
{/if} {/if}
<!-- Impersonation Banner -->
{#if $auth.user?.impersonatedBy}
<div class="impersonation-banner">
<div class="impersonation-content">
<span class="warning-icon">⚠️</span>
<span class="impersonation-text">
You are currently impersonating <strong>{$auth.user.email}</strong>
</span>
<button
class="stop-impersonation-btn"
on:click={handleStopImpersonation}
disabled={stoppingImpersonation}
>
{stoppingImpersonation ? 'Stopping...' : 'Stop Impersonation'}
</button>
</div>
</div>
{/if}
<main> <main>
<slot /> <slot />
</main> </main>
@@ -56,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 {
@@ -66,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;
@@ -81,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;
} }
@@ -93,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;
@@ -106,30 +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 {
background-color: var(--color-admin-bg);
color: var(--color-admin-text);
font-weight: 600;
}
.admin-link:hover {
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;
@@ -139,4 +217,51 @@
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Impersonation Banner */
.impersonation-banner {
background-color: var(--impersonation-bg);
border: 2px solid var(--impersonation-border);
padding: 0.75rem 1rem;
}
.impersonation-content {
max-width: 1600px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.warning-icon {
font-size: 1.25rem;
}
.impersonation-text {
flex: 1;
font-size: 0.95rem;
color: var(--impersonation-text);
}
.stop-impersonation-btn {
background-color: var(--color-warning);
color: var(--impersonation-text);
border: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.stop-impersonation-btn:hover:not(:disabled) {
background-color: var(--color-warning-hover);
}
.stop-impersonation-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>

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>

File diff suppressed because it is too large Load Diff

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,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { authAPI } from '$lib/api.js'; import { browser } from '$app/environment';
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';
@@ -16,23 +17,35 @@
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() {
try { try {
loading = true; loading = true;
const response = await authAPI.getProfile(); const response = await authAPI.getProfile();
console.log('Loaded profile:', response.user);
if (response.user) { if (response.user) {
lotwUsername = response.user.lotwUsername || ''; lotwUsername = response.user.lotwUsername || '';
lotwPassword = ''; // Never pre-fill password for security lotwPassword = ''; // Never pre-fill password for security
hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword); hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
dclApiKey = response.user.dclApiKey || ''; dclApiKey = response.user.dclApiKey || '';
hasDCLCredentials = !!response.user.dclApiKey; hasDCLCredentials = !!response.user.dclApiKey;
console.log('Has LoTW credentials:', hasLoTWCredentials, 'Has DCL credentials:', hasDCLCredentials);
} }
} catch (err) { } catch (err) {
console.error('Failed to load profile:', err); console.error('Failed to load profile:', err);
@@ -42,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();
@@ -50,8 +78,6 @@
error = null; error = null;
successLoTW = false; successLoTW = false;
console.log('Saving LoTW credentials:', { lotwUsername, hasPassword: !!lotwPassword });
await authAPI.updateLoTWCredentials({ await authAPI.updateLoTWCredentials({
lotwUsername, lotwUsername,
lotwPassword lotwPassword
@@ -78,8 +104,6 @@
error = null; error = null;
successDCL = false; successDCL = false;
console.log('Saving DCL credentials:', { hasApiKey: !!dclApiKey });
await authAPI.updateDCLCredentials({ await authAPI.updateDCLCredentials({
dclApiKey dclApiKey
}); });
@@ -97,9 +121,46 @@
} }
} }
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();
goto('/auth/login'); // Use hard redirect to ensure proper navigation after logout
if (browser) {
window.location.href = '/auth/login';
}
} }
</script> </script>
@@ -243,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>
@@ -269,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 {
@@ -312,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;
} }
@@ -361,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;
@@ -396,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 {
@@ -410,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