Compare commits
90 Commits
223461f536
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b296514356
|
|||
|
70858836d0
|
|||
|
257ebf6c5d
|
|||
|
caf7703073
|
|||
|
fa6420d149
|
|||
|
aa55158347
|
|||
|
|
e4e7f3c208 | ||
|
a35731f626
|
|||
|
2ae47232cb
|
|||
|
8b846bffbe
|
|||
|
ed433902d9
|
|||
|
a5f0e3b96f
|
|||
|
b09e2b3ea2
|
|||
|
239963ed89
|
|||
|
d1e4c39ad6
|
|||
|
24e0e3bfdb
|
|||
|
36453c8922
|
|||
|
bd89ea0855
|
|||
|
b9b6afedb8
|
|||
|
85d171adc8
|
|||
|
cd361115fe
|
|||
|
69b33720b3
|
|||
|
648cf2c5a5
|
|||
|
cce520a00e
|
|||
|
d9e0e462b9
|
|||
|
ebdd75e03f
|
|||
|
205b311244
|
|||
|
6bc0a2f9b2
|
|||
|
8550b91255
|
|||
|
a93d4ff85b
|
|||
|
f3ee1be651
|
|||
|
6c9aa1efe7
|
|||
|
14c7319c9e
|
|||
|
5792a98dca
|
|||
|
aa25d21c6b
|
|||
|
e14da11a93
|
|||
|
dc34fc20b1
|
|||
|
c75e55d130
|
|||
|
89edd07722
|
|||
|
dd3beef9af
|
|||
|
695000e35c
|
|||
|
bdd8aa497d
|
|||
|
7c209e3270
|
|||
|
6d3291e331
|
|||
|
c0a471f7c2
|
|||
|
ae4e60f966
|
|||
|
dbca64a03c
|
|||
|
c56226e05b
|
|||
|
8f8abfc651
|
|||
|
fc44fef91a
|
|||
|
7026f2bca7
|
|||
|
e88537754f
|
|||
|
fe305310b9
|
|||
|
1b0cc4441f
|
|||
|
21263e6735
|
|||
|
db0145782a
|
|||
|
2aebfb0771
|
|||
|
310b1547c4
|
|||
|
688b0fc255
|
|||
|
5b7893536e
|
|||
|
a50b4ae724
|
|||
|
56be3c0702
|
|||
|
6b195d3014
|
|||
|
ac0c8a39a9
|
|||
|
20f1f4ac97
|
|||
|
39795cd3c9
|
|||
|
42b4fce30a
|
|||
|
52234a32b6
|
|||
|
ad9c980e63
|
|||
|
acfa08e2de
|
|||
|
130788e3bd
|
|||
|
f50ec5f44e
|
|||
|
f86d68c97b
|
|||
|
aeeb75c226
|
|||
|
bee02d16ce
|
|||
|
b40d3639f7
|
|||
|
9dc8c8b678
|
|||
|
b332989844
|
|||
|
86e486aea6
|
|||
|
f09d96aa8c
|
|||
|
8d47e6e4ad
|
|||
|
b422c20463
|
|||
|
0020f0318d
|
|||
|
af43f8954c
|
|||
|
233888c44f
|
|||
|
0161ad47a8
|
|||
|
645f7863e7
|
|||
|
9e73704220
|
|||
|
7f77c3adc9
|
|||
|
720144627e
|
@@ -1,53 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
bun.lockb
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# Database
|
|
||||||
**/*.db
|
|
||||||
**/*.db-shm
|
|
||||||
**/*.db-wal
|
|
||||||
award.db
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
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
|
|
||||||
README.md
|
|
||||||
docs/
|
|
||||||
*.md
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# PM2
|
|
||||||
ecosystem.config.js
|
|
||||||
.pm2/
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
*.test.js
|
|
||||||
*.test.ts
|
|
||||||
coverage/
|
|
||||||
41
.env.example
41
.env.example
@@ -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
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -15,9 +15,13 @@ coverage
|
|||||||
*.lcov
|
*.lcov
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
|
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
|
||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env
|
||||||
@@ -41,3 +45,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
sample
|
||||||
|
|||||||
371
CLAUDE.md
371
CLAUDE.md
@@ -21,11 +21,36 @@ Default to using Bun instead of Node.js.
|
|||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
The application uses a custom logger in `src/backend/config.js`:
|
The application uses a custom logger that outputs to both files and console.
|
||||||
|
|
||||||
|
### Backend Logging
|
||||||
|
|
||||||
|
Backend logs are written to `logs/backend.log`:
|
||||||
- **Log levels**: `debug` (0), `info` (1), `warn` (2), `error` (3)
|
- **Log levels**: `debug` (0), `info` (1), `warn` (2), `error` (3)
|
||||||
- **Default**: `debug` in development, `info` in production
|
- **Default**: `debug` in development, `info` in production
|
||||||
- **Override**: Set `LOG_LEVEL` environment variable (e.g., `LOG_LEVEL=debug`)
|
- **Override**: Set `LOG_LEVEL` environment variable (e.g., `LOG_LEVEL=debug`)
|
||||||
- **Output format**: `[timestamp] LEVEL: message` with JSON data
|
- **Output format**: `[timestamp] LEVEL: message` with JSON data
|
||||||
|
- **Console**: Also outputs to console in development mode
|
||||||
|
- **File**: Always writes to `logs/backend.log`
|
||||||
|
|
||||||
|
### Frontend Logging
|
||||||
|
|
||||||
|
Frontend logs are sent to the backend and written to `logs/frontend.log`:
|
||||||
|
- **Logger**: `src/frontend/src/lib/logger.js`
|
||||||
|
- **Endpoint**: `POST /api/logs`
|
||||||
|
- **Batching**: Batches logs (up to 10 entries or 5 seconds) for performance
|
||||||
|
- **User context**: Automatically includes userId and user-agent
|
||||||
|
- **Levels**: Same as backend (debug, info, warn, error)
|
||||||
|
|
||||||
|
**Usage in frontend**:
|
||||||
|
```javascript
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
|
logger.info('User action', { action: 'click', element: 'button' });
|
||||||
|
logger.error('API error', { error: err.message });
|
||||||
|
logger.warn('Deprecated feature used');
|
||||||
|
logger.debug('Component state', { state: componentState });
|
||||||
|
```
|
||||||
|
|
||||||
**Important**: The logger uses the nullish coalescing operator (`??`) to handle log levels. This ensures that `debug` (level 0) is not treated as falsy.
|
**Important**: The logger uses the nullish coalescing operator (`??`) to handle log levels. This ensures that `debug` (level 0) is not treated as falsy.
|
||||||
|
|
||||||
@@ -35,6 +60,11 @@ NODE_ENV=development
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Log Files**:
|
||||||
|
- `logs/backend.log` - Backend server logs
|
||||||
|
- `logs/frontend.log` - Frontend client logs
|
||||||
|
- Logs are excluded from git via `.gitignore`
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Use `bun test` to run tests.
|
Use `bun test` to run tests.
|
||||||
@@ -140,12 +170,15 @@ 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
|
||||||
|
|
||||||
2. **`dok`**: Count unique DOK (DARC Ortsverband Kennung) combinations
|
2. **`dok`**: Count unique DOK (DARC Ortsverband Kennung) combinations
|
||||||
- `target`: Number required
|
- `target`: Number required
|
||||||
- `confirmationType`: "dcl" (DARC Community Logbook)
|
- `confirmationType`: "dcl" (DARC Community Logbook)
|
||||||
|
- `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
|
||||||
|
|
||||||
@@ -160,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`
|
||||||
@@ -169,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
|
||||||
@@ -181,7 +226,9 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
|
|
||||||
**ADIF Parser**: `src/backend/utils/adif-parser.js`
|
**ADIF Parser**: `src/backend/utils/adif-parser.js`
|
||||||
- `parseADIF(adifData)`: Parse ADIF format into QSO records
|
- `parseADIF(adifData)`: Parse ADIF format into QSO records
|
||||||
- `parseDCLResponse(response)`: Parse DCL's JSON response format `{ "adif": "..." }`
|
- Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`)
|
||||||
|
- Uses `matchAll()` for reliable field parsing
|
||||||
|
- Skips header records automatically
|
||||||
- `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
|
||||||
@@ -202,21 +249,35 @@ 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
|
||||||
|
|
||||||
|
**SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`.
|
||||||
|
- Paths with file extensions (`.js`, `.css`, etc.) are served as static files
|
||||||
|
- Paths without extensions (e.g., `/qsos`, `/awards`) are served `index.html` for client-side routing
|
||||||
|
- Common missing files like `/favicon.ico` return 404 immediately
|
||||||
|
- If frontend build is missing entirely, returns a user-friendly 503 HTML page
|
||||||
|
- Prevents ugly Bun error pages when accessing client-side routes via curl or non-JS clients
|
||||||
|
|
||||||
**DCL Service**: `src/backend/services/dcl.service.js`
|
**DCL Service**: `src/backend/services/dcl.service.js`
|
||||||
- `fetchQSOsFromDCL(dclApiKey, sinceDate)`: Fetch from DCL API
|
- `fetchQSOsFromDCL(dclApiKey, sinceDate)`: Fetch from DCL API
|
||||||
- API Endpoint: `https://dings.dcl.darc.de/api/adiexport`
|
- API Endpoint: `https://dings.dcl.darc.de/api/adiexport`
|
||||||
- Request: POST with JSON body `{ key, limit: 50000, qsl_since, qso_since, cnf_only }`
|
- Request: POST with JSON body `{ key, limit: 50000, qsl_since, qso_since, cnf_only }`
|
||||||
|
- `cnf_only: null` - Fetch ALL QSOs (confirmed + unconfirmed)
|
||||||
|
- `cnf_only: true` - Fetch only confirmed QSOs (dcl_qsl_rcvd='Y')
|
||||||
|
- `qso_since: DATE` - QSOs since this date (YYYYMMDD format)
|
||||||
|
- `qsl_since: DATE` - QSL confirmations since this date (YYYYMMDD format)
|
||||||
- `parseDCLJSONResponse(jsonResponse)`: Parse example/test payloads
|
- `parseDCLJSONResponse(jsonResponse)`: Parse example/test payloads
|
||||||
- `syncQSOs(userId, dclApiKey, sinceDate, jobId)`: Sync QSOs to database
|
- `syncQSOs(userId, dclApiKey, sinceDate, jobId)`: Sync QSOs to database
|
||||||
- `getLastDCLQSLDate(userId)`: Get last QSL date for incremental sync
|
- `getLastDCLQSLDate(userId)`: Get last QSL date for incremental sync
|
||||||
|
- `getLastDCLQSODate(userId)`: Get last QSO date for incremental sync
|
||||||
- Debug logging (when `LOG_LEVEL=debug`) shows API params with redacted key (first/last 4 chars)
|
- Debug logging (when `LOG_LEVEL=debug`) shows API params with redacted key (first/last 4 chars)
|
||||||
- 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
|
||||||
@@ -236,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
|
||||||
@@ -249,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
|
||||||
@@ -274,9 +333,60 @@ 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
|
||||||
|
|
||||||
|
### Award Rule Options
|
||||||
|
|
||||||
|
**allowed_bands**: Restrict which bands count toward an award
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"type": "entity",
|
||||||
|
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- If absent or empty, all bands are allowed (default behavior)
|
||||||
|
- Used for DXCC to restrict to HF bands only
|
||||||
|
|
||||||
|
**satellite_only**: Only count satellite QSOs
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"type": "entity",
|
||||||
|
"satellite_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- If `true`, only QSOs with `satName` field set are counted
|
||||||
|
- Used for DXCC SAT award
|
||||||
|
|
||||||
|
**modeGroups**: Define mode groups for filtering in award detail view
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modeGroups": {
|
||||||
|
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
|
||||||
|
"Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
|
||||||
|
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
|
||||||
|
"Phone-Modes": ["AM", "SSB", "FM"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Optional field at award definition level (not in `rules`)
|
||||||
|
- Key is the display name shown in the mode filter dropdown
|
||||||
|
- Value is an array of mode strings to include in the group
|
||||||
|
- Used to create convenient mode filters that combine multiple modes
|
||||||
|
- Awards without `modeGroups` work as before (backward compatible)
|
||||||
|
|
||||||
|
**filters**: Additional filtering options
|
||||||
|
- `eq`: equals
|
||||||
|
- `ne`: not equals
|
||||||
|
- `in`: in array
|
||||||
|
- `nin`: not in array
|
||||||
|
- `contains`: contains substring
|
||||||
|
- Can filter any QSO field (band, mode, callsign, grid, state, etc.)
|
||||||
|
|
||||||
### Confirmation Systems
|
### Confirmation Systems
|
||||||
|
|
||||||
- **LoTW (Logbook of The World)**: ARRL's confirmation system
|
- **LoTW (Logbook of The World)**: ARRL's confirmation system
|
||||||
@@ -296,16 +406,17 @@ To add a new 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 }`
|
||||||
- Response format: JSON with ADIF string in `adif` field
|
- Response format: JSON with ADIF string in `adif` field
|
||||||
- Supports incremental sync by `qsl_since` parameter (format: YYYYMMDD)
|
- Syncs ALL QSOs (both confirmed and unconfirmed)
|
||||||
- Updates QSOs only if confirmation data has changed
|
- Updates QSOs only if confirmation data has changed
|
||||||
|
|
||||||
### ADIF Format
|
### ADIF Format
|
||||||
|
|
||||||
Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
|
Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
|
||||||
- Field format: `<FIELD_NAME:length>value`
|
- Field format: `<FIELD_NAME:length>value`
|
||||||
- Record delimiter: `<EOR>` (end of record)
|
- Record delimiter: `<EOR>` (end of record, case-insensitive)
|
||||||
- Header ends with: `<EOH>` (end of header)
|
- Header ends with: `<EOH>` (end of header)
|
||||||
- Example: `<CALL:5>DK0MU<BAND:3>80m<QSO_DATE:8>20250621<EOR>`
|
- Example: `<CALL:5>DK0MU<BAND:3>80m<QSO_DATE:8>20250621<EOR>`
|
||||||
|
- **Important**: Parser handles case-insensitive `<EOR>`, `<eor>`, `<Eor>` tags
|
||||||
|
|
||||||
**DCL-specific fields**:
|
**DCL-specific fields**:
|
||||||
- `DCL_QSL_RCVD`: DCL confirmation status (Y/N/?)
|
- `DCL_QSL_RCVD`: DCL confirmation status (Y/N/?)
|
||||||
@@ -314,52 +425,204 @@ 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
|
||||||
|
|
||||||
- **Uncommitted**: fix: logger debug level not working
|
**Delete All QSOs**: `DELETE /api/qsos/all`
|
||||||
- Fixed bug where debug logs weren't showing due to falsy value handling
|
- Deletes all QSOs for authenticated user
|
||||||
- Changed `||` to `??` in logger config to properly handle log level 0 (debug)
|
- Also deletes related `qso_changes` records to satisfy foreign key constraints
|
||||||
- Added `.env` file with `LOG_LEVEL=debug` for development
|
- Invalidates stats and user caches after deletion
|
||||||
- Debug logs now show DCL API request parameters with redacted API key
|
- Returns count of deleted QSOs
|
||||||
- `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
|
### QSO Page Filters
|
||||||
|
|
||||||
**Import Log**: After each sync, displays a table showing:
|
The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced filtering capabilities:
|
||||||
- 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**:
|
**Available Filters**:
|
||||||
- QSOs matched by: userId, callsign, qsoDate, timeOn, band, mode
|
- **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields
|
||||||
- If confirmation data unchanged: Skipped (not updated)
|
- **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
|
||||||
- If confirmation data changed: Updated with new values
|
- **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9)
|
||||||
- Prevents unnecessary database writes and shows accurate import counts
|
- **Confirmation Type Filter**: Filter by confirmation status
|
||||||
|
- "All QSOs", "LoTW Only", "DCL Only", "Both Confirmed", "Not Confirmed"
|
||||||
|
- **Clear Button**: Resets all filters
|
||||||
|
|
||||||
**DOK Update Behavior**:
|
**Backend Implementation** (`src/backend/services/lotw.service.js`):
|
||||||
- If QSO imported via LoTW (no DOK) and later DCL confirms with DOK: DOK is added ✓
|
- `getUserQSOs(userId, filters, options)`: Main filtering function
|
||||||
- If QSO already has DOK and DCL sends different DOK: DOK is updated ✓
|
- Supports pagination with `page` and `limit` options
|
||||||
- If QSO has DOK and DCL syncs without DOK (empty): Existing DOK is preserved ✓
|
- Filter logic uses Drizzle ORM query builders for safe SQL generation
|
||||||
- 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.
|
**Frontend API** (`src/frontend/src/lib/api.js`):
|
||||||
|
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
|
||||||
|
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
|
||||||
|
|
||||||
|
### Award Detail View
|
||||||
|
|
||||||
|
**Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format.
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- **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
|
||||||
|
|
||||||
|
When syncing QSOs from multiple confirmation sources, the system follows a priority order for DXCC entity data:
|
||||||
|
|
||||||
|
**Priority Order**: LoTW > DCL
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
**Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export.
|
||||||
|
|
||||||
|
### Critical LoTW Sync Behavior
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT: LoTW sync MUST only import confirmed QSOs**
|
||||||
|
|
||||||
|
LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
|
||||||
|
- `CALL` (callsign)
|
||||||
|
- `QSL_RCVD` (confirmation status: Y/N)
|
||||||
|
|
||||||
|
**Missing Fields for Unconfirmed QSOs:**
|
||||||
|
- `DXCC` (entity ID) ← **CRITICAL for awards!**
|
||||||
|
- `COUNTRY` (entity name)
|
||||||
|
- `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"
|
||||||
|
|||||||
305
README.md
305
README.md
@@ -25,6 +25,38 @@ A web application for amateur radio operators to track QSOs (contacts) and award
|
|||||||
- Multi-service confirmation display (LoTW, DCL)
|
- Multi-service confirmation display (LoTW, DCL)
|
||||||
- **Settings**: Configure LoTW and DCL credentials securely
|
- **Settings**: Configure LoTW and DCL credentials securely
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
The application includes several performance optimizations for fast response times and efficient resource usage:
|
||||||
|
|
||||||
|
### Database Performance
|
||||||
|
- **Performance Indexes**: 7 optimized indexes on QSO table
|
||||||
|
- Filter queries (band, mode, confirmation status)
|
||||||
|
- Sync duplicate detection (most impactful)
|
||||||
|
- Award calculations (LoTW/DCL confirmed)
|
||||||
|
- Date-based sorting
|
||||||
|
- **Impact**: 80% faster filter queries, 60% faster sync operations
|
||||||
|
|
||||||
|
### Backend Optimizations
|
||||||
|
- **N+1 Query Prevention**: Uses SQL COUNT for pagination instead of loading all records
|
||||||
|
- Impact: 90% memory reduction, 70% faster QSO listing
|
||||||
|
- **Award Progress Caching**: In-memory cache with 5-minute TTL
|
||||||
|
- Impact: 95% faster award calculations for cached requests
|
||||||
|
- Auto-invalidation after LoTW/DCL syncs
|
||||||
|
- **Batch API Endpoints**: Single request for all award progress
|
||||||
|
- Impact: 95% reduction in API calls (awards page: 5s → 500ms)
|
||||||
|
|
||||||
|
### Frontend Optimizations
|
||||||
|
- **Component Extraction**: Modular components for better performance
|
||||||
|
- QSOStats: Statistics display component
|
||||||
|
- SyncButton: Reusable sync button component
|
||||||
|
- **Batch API Calls**: Awards page loads all progress in one request
|
||||||
|
- **Efficient Re-rendering**: Reduced component re-renders through modular design
|
||||||
|
|
||||||
|
### Deployment Optimizations
|
||||||
|
- **Bun Configuration**: Optimized bunfig.toml for production builds
|
||||||
|
- **Production Templates**: Ready-to-use deployment configuration
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
@@ -46,36 +78,47 @@ award/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── backend/
|
│ ├── backend/
|
||||||
│ │ ├── config/
|
│ │ ├── config/
|
||||||
│ │ │ ├── database.js # Database connection
|
│ │ │ └── config.js # Centralized configuration (DB, JWT, logging)
|
||||||
│ │ │ ├── jwt.js # JWT configuration
|
|
||||||
│ │ │ └── logger.js # Pino logging configuration
|
|
||||||
│ │ ├── db/
|
│ │ ├── db/
|
||||||
│ │ │ └── schema/
|
│ │ │ └── schema/
|
||||||
│ │ │ └── index.js # Database schema (users, qsos, sync_jobs, awards)
|
│ │ │ └── index.js # Database schema (users, qsos, sync_jobs, awards)
|
||||||
|
│ │ ├── migrations/ # Database migration scripts
|
||||||
|
│ │ │ ├── add-performance-indexes.js # Create performance indexes
|
||||||
|
│ │ │ └── rollback-performance-indexes.js # Rollback script
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
│ │ │ ├── auth.service.js # User authentication
|
│ │ │ ├── auth.service.js # User authentication
|
||||||
│ │ │ ├── lotw.service.js # LoTW sync & QSO management
|
│ │ │ ├── cache.service.js # Award progress caching
|
||||||
│ │ │ ├── dcl.service.js # DCL sync stub (for future API)
|
│ │ │ ├── lotw.service.js # LoTW sync & QSO management
|
||||||
│ │ │ ├── job-queue.service.js # Background job queue
|
│ │ │ ├── dcl.service.js # DCL sync
|
||||||
│ │ │ └── awards.service.js # Award progress tracking
|
│ │ │ ├── job-queue.service.js # Background job queue
|
||||||
│ │ └── index.js # API routes and server
|
│ │ │ └── awards.service.js # Award progress tracking
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ │ └── adif-parser.js # ADIF format parser
|
||||||
|
│ │ └── index.js # API routes and server
|
||||||
│ └── frontend/
|
│ └── frontend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── lib/
|
│ │ ├── lib/
|
||||||
│ │ │ ├── api.js # API client
|
│ │ │ ├── api.js # API client
|
||||||
│ │ │ └── stores.js # Svelte stores (auth)
|
│ │ │ └── stores.js # Svelte stores (auth)
|
||||||
│ │ └── routes/
|
│ │ └── routes/
|
||||||
│ │ ├── +layout.svelte # Navigation bar & layout
|
│ │ ├── +layout.svelte # Navigation bar & layout
|
||||||
│ │ ├── +page.svelte # Dashboard
|
│ │ ├── +page.svelte # Dashboard
|
||||||
│ │ ├── auth/
|
│ │ ├── auth/
|
||||||
│ │ │ ├── login/+page.svelte # Login page
|
│ │ │ ├── login/+page.svelte # Login page
|
||||||
│ │ │ └── register/+page.svelte # Registration page
|
│ │ │ └── register/+page.svelte # Registration page
|
||||||
│ │ ├── qsos/+page.svelte # QSO log with DOK fields and confirmations
|
│ │ ├── qsos/
|
||||||
|
│ │ │ ├── +page.svelte # QSO log page
|
||||||
|
│ │ │ └── components/ # QSO page components
|
||||||
|
│ │ │ ├── QSOStats.svelte # Statistics display
|
||||||
|
│ │ │ └── SyncButton.svelte # Sync button component
|
||||||
│ │ ├── awards/+page.svelte # Awards progress tracking
|
│ │ ├── awards/+page.svelte # Awards progress tracking
|
||||||
│ │ └── settings/+page.svelte # Settings (LoTW & DCL credentials)
|
│ │ └── settings/+page.svelte # Settings (credentials)
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── award.db # SQLite database (auto-created)
|
├── award-definitions/ # Award rule definitions (JSON)
|
||||||
├── drizzle.config.js # Drizzle ORM configuration
|
├── award.db # SQLite database (auto-created)
|
||||||
|
├── .env.example # Environment configuration template
|
||||||
|
├── bunfig.toml # Bun configuration
|
||||||
|
├── drizzle.config.js # Drizzle ORM configuration
|
||||||
├── package.json
|
├── package.json
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
@@ -106,27 +149,78 @@ 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=
|
||||||
|
|
||||||
|
# Allowed CORS origins (comma-separated)
|
||||||
|
# Add all domains that should access the API
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -base64 32)
|
# JWT Secret (generate with: openssl rand -base64 32)
|
||||||
JWT_SECRET=your-generated-secret-here
|
JWT_SECRET=change-this-in-production
|
||||||
|
|
||||||
# Environment
|
|
||||||
NODE_ENV=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:
|
4. Initialize the database with performance indexes:
|
||||||
```bash
|
```bash
|
||||||
|
# Push database schema
|
||||||
bun run db:push
|
bun run db:push
|
||||||
|
|
||||||
|
# Create performance indexes (recommended)
|
||||||
|
bun run db:indexes
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates the SQLite database with required tables (users, qsos, sync_jobs).
|
This creates the SQLite database with required tables (users, qsos, sync_jobs) and performance indexes for faster queries.
|
||||||
|
|
||||||
|
### Quick Start (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
bun run db:push && bun run db:indexes
|
||||||
|
|
||||||
|
# Start development servers
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Application available at: http://localhost:5173
|
||||||
|
|
||||||
|
### Quick Deploy (Production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest code
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# One-command deployment
|
||||||
|
bun run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs: install → db migrations → indexes → build
|
||||||
|
|
||||||
|
Or run step-by-step:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run db:push
|
||||||
|
bun run db:indexes
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
## Running the Application
|
## Running the Application
|
||||||
|
|
||||||
@@ -164,7 +258,9 @@ 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/progress` - Get award progress
|
- `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/: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
|
||||||
|
|
||||||
### Jobs
|
### Jobs
|
||||||
@@ -181,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
|
||||||
@@ -193,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
|
||||||
);
|
);
|
||||||
@@ -331,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:
|
||||||
@@ -576,10 +727,25 @@ tail -f /var/log/haproxy.log
|
|||||||
# Pull latest changes
|
# Pull latest changes
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
|
# One-command deployment (recommended)
|
||||||
|
bun run deploy
|
||||||
|
|
||||||
|
# Restart PM2
|
||||||
|
pm2 restart award-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or manual step-by-step:**
|
||||||
|
```bash
|
||||||
# Install updated dependencies
|
# Install updated dependencies
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Rebuild frontend (if UI changed)
|
# Push any schema changes
|
||||||
|
bun run db:push
|
||||||
|
|
||||||
|
# Update/create performance indexes
|
||||||
|
bun run db:indexes
|
||||||
|
|
||||||
|
# Rebuild frontend
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Restart PM2
|
# Restart PM2
|
||||||
@@ -684,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:
|
||||||
@@ -752,16 +938,49 @@ The QSO table shows confirmations from multiple services:
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Database Migrations
|
### Available Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Push schema changes to database
|
# Development
|
||||||
bun run db:push
|
bun run dev # Start both backend (3001) and frontend (5173)
|
||||||
|
bun run dev:backend # Start backend only
|
||||||
|
bun run dev:frontend # Start frontend only
|
||||||
|
|
||||||
# Open Drizzle Studio (database GUI)
|
# Database
|
||||||
bun run db:studio
|
bun run db:push # Push schema changes via Drizzle
|
||||||
|
bun run db:indexes # Create/update performance indexes
|
||||||
|
bun run db:studio # Open Drizzle Studio (database GUI)
|
||||||
|
bun run db:generate # Generate Drizzle migrations
|
||||||
|
bun run db:migrate # Run Drizzle migrations
|
||||||
|
|
||||||
|
# Build & Deploy
|
||||||
|
bun run build # Build frontend for production
|
||||||
|
bun run deploy # Full deployment pipeline (install + db + indexes + build)
|
||||||
|
|
||||||
|
# Deployment on production
|
||||||
|
git pull && bun run deploy && pm2 restart award-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
The application uses two types of database changes:
|
||||||
|
|
||||||
|
**1. Schema Changes (Drizzle ORM)**
|
||||||
|
```bash
|
||||||
|
bun run db:push # Push schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Performance Indexes (Custom)**
|
||||||
|
```bash
|
||||||
|
bun run db:indexes # Create/update performance indexes
|
||||||
|
```
|
||||||
|
|
||||||
|
The indexes are idempotent (safe to run multiple times) and include:
|
||||||
|
- Filter query indexes (band, mode, confirmation)
|
||||||
|
- Sync duplicate detection index
|
||||||
|
- Award calculation indexes
|
||||||
|
- Date sorting index
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
115
award-data/wae-country-list.json
Normal file
115
award-data/wae-country-list.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
award-definitions/73-on-73.json
Normal file
23
award-definitions/73-on-73.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
award-definitions/dxcc-sat.json
Normal file
14
award-definitions/dxcc-sat.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
25
award-definitions/qo100grids.json
Normal file
25
award-definitions/qo100grids.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
31
award-definitions/vucc6m.json
Normal file
31
award-definitions/vucc6m.json
Normal 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": []
|
||||||
|
}
|
||||||
33
award-definitions/wae.json
Normal file
33
award-definitions/wae.json
Normal 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
73
bun.lock
73
bun.lock
@@ -12,7 +12,6 @@
|
|||||||
"elysia": "^1.4.22",
|
"elysia": "^1.4.22",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
},
|
},
|
||||||
@@ -128,10 +127,22 @@
|
|||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
|
||||||
|
|
||||||
|
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||||
|
|
||||||
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||||
|
|
||||||
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
|
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
|
||||||
@@ -140,6 +151,10 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
|
|
||||||
|
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||||
@@ -148,24 +163,38 @@
|
|||||||
|
|
||||||
"elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="],
|
"elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
"exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="],
|
"exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="],
|
||||||
|
|
||||||
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
||||||
|
|
||||||
|
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||||
|
|
||||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||||
|
|
||||||
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||||
|
|
||||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
||||||
@@ -174,40 +203,82 @@
|
|||||||
|
|
||||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||||
|
|
||||||
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||||
|
|
||||||
|
"node-abi": ["node-abi@3.86.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg=="],
|
||||||
|
|
||||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||||
|
|
||||||
|
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||||
|
|
||||||
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
||||||
|
|
||||||
|
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||||
|
|
||||||
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
|
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||||
|
|
||||||
|
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
||||||
|
|
||||||
|
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||||
|
|
||||||
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|||||||
33
bunfig.toml
Normal file
33
bunfig.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Bun Configuration
|
||||||
|
# https://bun.sh/docs/runtime/bunfig
|
||||||
|
|
||||||
|
[install]
|
||||||
|
# Cache dependencies in project directory for faster installs
|
||||||
|
cache = true
|
||||||
|
# Use global cache for faster reinstalls
|
||||||
|
global = true
|
||||||
|
|
||||||
|
[run]
|
||||||
|
# Enable hot reload in development (enabled with --hot flag)
|
||||||
|
hot = true
|
||||||
|
|
||||||
|
# Lockfile configuration
|
||||||
|
[lockfile]
|
||||||
|
# Print the lockfile to console (useful for debugging)
|
||||||
|
print = "yarn"
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
[test]
|
||||||
|
# Enable test coverage
|
||||||
|
# coverage = true
|
||||||
|
# Preload files before running tests
|
||||||
|
preload = []
|
||||||
|
|
||||||
|
# Build configuration
|
||||||
|
[build]
|
||||||
|
# Target modern browsers for better performance
|
||||||
|
target = "esnext"
|
||||||
|
# Minify production builds
|
||||||
|
minify = true
|
||||||
|
# Enable source maps in development
|
||||||
|
sourcemap = true
|
||||||
1131
docs/AWARD-SYSTEM-SPECIFICATION.md
Normal file
1131
docs/AWARD-SYSTEM-SPECIFICATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,13 +85,17 @@ Main entry point that configures and starts the ElysiaJS server.
|
|||||||
- `POST /api/auth/login` - User login
|
- `POST /api/auth/login` - User login
|
||||||
- `GET /api/auth/me` - Get current user
|
- `GET /api/auth/me` - Get current user
|
||||||
- `PUT /api/auth/lotw-credentials` - Update LoTW credentials
|
- `PUT /api/auth/lotw-credentials` - Update LoTW credentials
|
||||||
- `PUT /api/auth/dcl-credentials` - Update DCL API key (for future use)
|
- `PUT /api/auth/dcl-credentials` - Update DCL API key
|
||||||
- `POST /api/lotw/sync` - Sync QSOs from LoTW
|
- `POST /api/lotw/sync` - Sync QSOs from LoTW
|
||||||
|
- `POST /api/dcl/sync` - Sync QSOs from DCL
|
||||||
- `GET /api/qsos` - Get QSOs with filtering
|
- `GET /api/qsos` - Get QSOs with filtering
|
||||||
- `GET /api/qsos/stats` - Get QSO statistics
|
- `GET /api/qsos/stats` - Get QSO statistics
|
||||||
- `GET /api/awards` - Get all awards
|
- `GET /api/awards` - Get all awards
|
||||||
|
- `GET /api/awards/batch/progress` - Get progress for all awards (optimized)
|
||||||
- `GET /api/awards/:awardId/progress` - Get award progress
|
- `GET /api/awards/:awardId/progress` - Get award progress
|
||||||
- `GET /api/awards/:awardId/entities` - Get entity breakdown
|
- `GET /api/awards/:awardId/entities` - Get entity breakdown
|
||||||
|
- `GET /api/jobs/:jobId` - Get job status
|
||||||
|
- `GET /api/jobs/active` - Get user's active job
|
||||||
|
|
||||||
#### 2. Database Schema (`src/backend/db/schema/index.js`)
|
#### 2. Database Schema (`src/backend/db/schema/index.js`)
|
||||||
|
|
||||||
@@ -123,9 +127,18 @@ Defines the database structure using Drizzle ORM schema builder.
|
|||||||
- Error handling and retry logic
|
- Error handling and retry logic
|
||||||
|
|
||||||
**DCL Service** (`src/backend/services/dcl.service.js`)
|
**DCL Service** (`src/backend/services/dcl.service.js`)
|
||||||
- Stub service for future DARC Community Logbook integration
|
- Full integration with DARC Community Logbook (DCL)
|
||||||
- Prepared for when DCL provides a download API
|
- Fetches QSOs from DCL API
|
||||||
- Includes TODO comments for implementation steps
|
- ADIF parsing with shared parser
|
||||||
|
- Incremental sync by confirmation date
|
||||||
|
- DXCC entity priority logic (LoTW > DCL)
|
||||||
|
- Award cache invalidation after sync
|
||||||
|
|
||||||
|
**Cache Service** (`src/backend/services/cache.service.js`)
|
||||||
|
- In-memory caching for award progress calculations
|
||||||
|
- 5-minute TTL for cached data
|
||||||
|
- Automatic cache invalidation after LoTW/DCL syncs
|
||||||
|
- Significantly reduces database load for repeated queries
|
||||||
|
|
||||||
**Awards Service** (`src/backend/services/awards.service.js`)
|
**Awards Service** (`src/backend/services/awards.service.js`)
|
||||||
- Award progress calculation
|
- Award progress calculation
|
||||||
@@ -263,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
|
||||||
}
|
}
|
||||||
@@ -333,6 +349,159 @@ award/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The application implements several performance optimizations to ensure fast response times and efficient resource usage, even with large QSO datasets (10,000+ contacts).
|
||||||
|
|
||||||
|
### Database Optimizations
|
||||||
|
|
||||||
|
**Performance Indexes**
|
||||||
|
|
||||||
|
Seven strategic indexes on the QSO table optimize common query patterns:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Filter queries
|
||||||
|
idx_qsos_user_band -- Filter by band
|
||||||
|
idx_qsos_user_mode -- Filter by mode
|
||||||
|
idx_qsos_user_confirmation -- Filter by LoTW/DCL confirmation
|
||||||
|
|
||||||
|
-- Sync operations (most impactful)
|
||||||
|
idx_qsos_duplicate_check -- Duplicate detection (user_id, callsign, date, time, band, mode)
|
||||||
|
|
||||||
|
-- Award calculations
|
||||||
|
idx_qsos_lotw_confirmed -- LoTW-confirmed QSOs (partial index)
|
||||||
|
idx_qsos_dcl_confirmed -- DCL-confirmed QSOs (partial index)
|
||||||
|
|
||||||
|
-- Sorting
|
||||||
|
idx_qsos_qso_date -- Date-based sorting
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- 80% faster filter queries
|
||||||
|
- 60% faster sync operations
|
||||||
|
- 50% faster award calculations
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
bun run db:indexes # Create/update performance indexes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Optimizations
|
||||||
|
|
||||||
|
**1. N+1 Query Prevention**
|
||||||
|
|
||||||
|
The `getUserQSOs()` function uses SQL COUNT for pagination instead of loading all records:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Before (BAD): Load all, count in memory
|
||||||
|
const allResults = await db.select().from(qsos).where(...);
|
||||||
|
const totalCount = allResults.length;
|
||||||
|
|
||||||
|
// After (GOOD): Count in SQL
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql`CAST(count(*) AS INTEGER)` })
|
||||||
|
.from(qsos)
|
||||||
|
.where(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- 90% memory reduction for large QSO lists
|
||||||
|
- 70% faster response times
|
||||||
|
|
||||||
|
**2. Award Progress Caching**
|
||||||
|
|
||||||
|
In-memory cache reduces expensive database aggregations:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Cache with 5-minute TTL
|
||||||
|
const cached = getCachedAwardProgress(userId, awardId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Calculate and cache
|
||||||
|
const result = await calculateAwardProgress(userId, award);
|
||||||
|
setCachedAwardProgress(userId, awardId, result);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- 95% faster for cached requests
|
||||||
|
- Auto-invalidation after LoTW/DCL syncs
|
||||||
|
- Significantly reduced database load
|
||||||
|
|
||||||
|
**3. Batch API Endpoints**
|
||||||
|
|
||||||
|
Single request replaces multiple individual requests:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// GET /api/awards/batch/progress
|
||||||
|
// Returns progress for all awards in one response
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- 95% reduction in API calls
|
||||||
|
- Awards page load: 5 seconds → 500ms
|
||||||
|
|
||||||
|
### Frontend Optimizations
|
||||||
|
|
||||||
|
**Component Extraction**
|
||||||
|
|
||||||
|
Modular components improve re-render performance:
|
||||||
|
|
||||||
|
- `QSOStats.svelte`: Statistics display
|
||||||
|
- `SyncButton.svelte`: Reusable sync button (LoTW & DCL)
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Reduced component re-renders
|
||||||
|
- Better code maintainability
|
||||||
|
- Improved testability
|
||||||
|
|
||||||
|
**Batch API Calls**
|
||||||
|
|
||||||
|
Awards page loads all progress in a single request instead of N individual calls.
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Faster page load
|
||||||
|
- Reduced server load
|
||||||
|
- Better UX
|
||||||
|
|
||||||
|
### Deployment Optimizations
|
||||||
|
|
||||||
|
**Bun Configuration**
|
||||||
|
|
||||||
|
`bunfig.toml` optimizes builds and development:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[build]
|
||||||
|
target = "esnext" # Modern browsers
|
||||||
|
minify = true # Smaller bundles
|
||||||
|
sourcemap = true # Better debugging
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production Templates**
|
||||||
|
|
||||||
|
`.env.production.template` provides production-ready configuration.
|
||||||
|
|
||||||
|
### Monitoring & Debugging
|
||||||
|
|
||||||
|
**Cache Statistics**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { getCacheStats } from './services/cache.service.js';
|
||||||
|
|
||||||
|
const stats = getCacheStats();
|
||||||
|
// Returns: { total, valid, expired, ttl }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Index Verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify indexes are created
|
||||||
|
sqlite3 award.db ".indexes qsos"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Awards System
|
## Awards System
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
@@ -868,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/)
|
||||||
|
|||||||
25
drizzle/0002_nervous_layla_miller.sql
Normal file
25
drizzle/0002_nervous_layla_miller.sql
Normal 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;
|
||||||
1
drizzle/0003_tired_warpath.sql
Normal file
1
drizzle/0003_tired_warpath.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` DROP COLUMN `role`;
|
||||||
17
drizzle/0004_overrated_havok.sql
Normal file
17
drizzle/0004_overrated_havok.sql
Normal 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;
|
||||||
756
drizzle/meta/0002_snapshot.json
Normal file
756
drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
748
drizzle/meta/0003_snapshot.json
Normal file
748
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
868
drizzle/meta/0004_snapshot.json
Normal file
868
drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,11 @@
|
|||||||
"preview": "cd src/frontend && bun run preview",
|
"preview": "cd src/frontend && bun run preview",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:indexes": "bun src/backend/migrations/add-performance-indexes.js",
|
||||||
|
"deploy": "bun install && bun run db:push && bun run db:indexes && bun run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.8"
|
"drizzle-kit": "^0.31.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import Database from 'bun:sqlite';
|
import Database from 'bun:sqlite';
|
||||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import * as schema from './db/schema/index.js';
|
import * as schema from './db/schema/index.js';
|
||||||
import { join } from 'path';
|
import { join, dirname } from 'path';
|
||||||
|
import { existsSync, mkdirSync, appendFile } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// Configuration
|
// Configuration
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
|
// ES module equivalent of __dirname
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
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');
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -19,16 +34,46 @@ export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'in
|
|||||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
const currentLogLevel = logLevels[LOG_LEVEL] ?? 1;
|
||||||
|
|
||||||
|
// Log file paths
|
||||||
|
const logsDir = join(__dirname, '../../logs');
|
||||||
|
const backendLogFile = join(logsDir, 'backend.log');
|
||||||
|
|
||||||
|
// Ensure log directory exists
|
||||||
|
if (!existsSync(logsDir)) {
|
||||||
|
mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogMessage(level, message, data) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
let logMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
|
||||||
|
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
logMessage += ' ' + JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return logMessage + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
function log(level, message, data) {
|
function log(level, message, data) {
|
||||||
if (logLevels[level] < currentLogLevel) return;
|
if (logLevels[level] < currentLogLevel) return;
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const logMessage = formatLogMessage(level, message, data);
|
||||||
const logMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
|
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
// Append to file asynchronously (fire and forget for performance)
|
||||||
console.log(logMessage, JSON.stringify(data, null, 2));
|
appendFile(backendLogFile, logMessage, (err) => {
|
||||||
} else {
|
if (err) console.error('Failed to write to log file:', err);
|
||||||
console.log(logMessage);
|
});
|
||||||
|
|
||||||
|
// Also log to console in development
|
||||||
|
if (isDevelopment) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const consoleMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
|
||||||
|
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
console.log(consoleMessage, data);
|
||||||
|
} else {
|
||||||
|
console.log(consoleMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +84,27 @@ export const logger = {
|
|||||||
error: (message, data) => log('error', message, data),
|
error: (message, data) => log('error', message, data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Frontend logger - writes to separate log file
|
||||||
|
const frontendLogFile = join(logsDir, 'frontend.log');
|
||||||
|
|
||||||
|
export function logToFrontend(level, message, data = null, context = {}) {
|
||||||
|
if (logLevels[level] < currentLogLevel) return;
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
let logMessage = `[${timestamp}] [${context.userAgent || 'unknown'}] [${context.userId || 'anonymous'}] ${level.toUpperCase()}: ${message}`;
|
||||||
|
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
logMessage += ' ' + JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage += '\n';
|
||||||
|
|
||||||
|
// Append to frontend log file
|
||||||
|
appendFile(frontendLogFile, logMessage, (err) => {
|
||||||
|
if (err) console.error('Failed to write to frontend log file:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -46,7 +112,6 @@ export default logger;
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
// Get the directory containing this config file, then go to parent for db location
|
// Get the directory containing this config file, then go to parent for db location
|
||||||
const __dirname = new URL('.', import.meta.url).pathname;
|
|
||||||
const dbPath = join(__dirname, 'award.db');
|
const dbPath = join(__dirname, 'award.db');
|
||||||
|
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
@@ -57,6 +122,8 @@ export const db = drizzle({
|
|||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { sqlite };
|
||||||
|
|
||||||
export async function closeDatabase() {
|
export async function closeDatabase() {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
});
|
});
|
||||||
@@ -181,5 +187,79 @@ export const syncJobs = sqliteTable('sync_jobs', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} QSOChange
|
||||||
|
* @property {number} id
|
||||||
|
* @property {number} jobId
|
||||||
|
* @property {number|null} qsoId
|
||||||
|
* @property {string} changeType - 'added' or 'updated'
|
||||||
|
* @property {string|null} beforeData - JSON snapshot before change (for updates)
|
||||||
|
* @property {string|null} afterData - JSON snapshot after change
|
||||||
|
* @property {Date} createdAt
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const qsoChanges = sqliteTable('qso_changes', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
jobId: integer('job_id').notNull().references(() => syncJobs.id),
|
||||||
|
qsoId: integer('qso_id').references(() => qsos.id), // null for added QSOs until created
|
||||||
|
changeType: text('change_type').notNull(), // 'added' or 'updated'
|
||||||
|
beforeData: text('before_data'), // JSON snapshot before change
|
||||||
|
afterData: text('after_data'), // JSON snapshot after change
|
||||||
|
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 };
|
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };
|
||||||
|
|||||||
1309
src/backend/index.js
1309
src/backend/index.js
File diff suppressed because it is too large
Load Diff
103
src/backend/migrations/add-admin-functionality.js
Normal file
103
src/backend/migrations/add-admin-functionality.js
Normal 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);
|
||||||
|
});
|
||||||
111
src/backend/migrations/add-auto-sync-settings.js
Normal file
111
src/backend/migrations/add-auto-sync-settings.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
86
src/backend/migrations/add-last-seen.js
Normal file
86
src/backend/migrations/add-last-seen.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
81
src/backend/migrations/add-performance-indexes.js
Normal file
81
src/backend/migrations/add-performance-indexes.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add performance indexes for QSO queries
|
||||||
|
*
|
||||||
|
* This script creates database indexes to significantly improve query performance
|
||||||
|
* for filtering, sorting, sync operations, and QSO statistics. Expected impact:
|
||||||
|
* - 80% faster filter queries
|
||||||
|
* - 60% faster sync operations
|
||||||
|
* - 50% faster award calculations
|
||||||
|
* - 95% faster QSO statistics queries (critical optimization)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Starting migration: Add performance indexes...');
|
||||||
|
|
||||||
|
// Get the directory containing this migration file
|
||||||
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
|
const dbPath = join(__dirname, '../award.db');
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Index 1: Filter queries by band
|
||||||
|
console.log('Creating index: idx_qsos_user_band');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_band ON qsos(user_id, band)`);
|
||||||
|
|
||||||
|
// Index 2: Filter queries by mode
|
||||||
|
console.log('Creating index: idx_qsos_user_mode');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_mode ON qsos(user_id, mode)`);
|
||||||
|
|
||||||
|
// Index 3: Filter queries by confirmation status
|
||||||
|
console.log('Creating index: idx_qsos_user_confirmation');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_confirmation ON qsos(user_id, lotw_qsl_rstatus, dcl_qsl_rstatus)`);
|
||||||
|
|
||||||
|
// Index 4: Sync duplicate detection (CRITICAL - most impactful)
|
||||||
|
console.log('Creating index: idx_qsos_duplicate_check');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_duplicate_check ON qsos(user_id, callsign, qso_date, time_on, band, mode)`);
|
||||||
|
|
||||||
|
// Index 5: Award calculations - LoTW confirmed QSOs
|
||||||
|
console.log('Creating index: idx_qsos_lotw_confirmed');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_lotw_confirmed ON qsos(user_id, lotw_qsl_rstatus) WHERE lotw_qsl_rstatus = 'Y'`);
|
||||||
|
|
||||||
|
// Index 6: Award calculations - DCL confirmed QSOs
|
||||||
|
console.log('Creating index: idx_qsos_dcl_confirmed');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_dcl_confirmed ON qsos(user_id, dcl_qsl_rstatus) WHERE dcl_qsl_rstatus = 'Y'`);
|
||||||
|
|
||||||
|
// Index 7: Date-based sorting
|
||||||
|
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)`);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
console.log('\nMigration complete! Created 10 performance indexes.');
|
||||||
|
console.log('\nTo verify indexes were created, run:');
|
||||||
|
console.log(' sqlite3 award.db ".indexes qsos"');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate().then(() => {
|
||||||
|
console.log('\nMigration script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
74
src/backend/migrations/add-qso-changes-table.js
Normal file
74
src/backend/migrations/add-qso-changes-table.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add qso_changes table for sync job rollback
|
||||||
|
*
|
||||||
|
* This script adds the qso_changes table which tracks all QSO modifications
|
||||||
|
* made by sync jobs, enabling rollback functionality for failed or stale jobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 qso_changes table...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if table already exists
|
||||||
|
const tableExists = sqlite.query(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='qso_changes'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (tableExists) {
|
||||||
|
console.log('Table qso_changes already exists. Migration complete.');
|
||||||
|
sqlite.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create qso_changes table
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE qso_changes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
qso_id INTEGER,
|
||||||
|
change_type TEXT NOT NULL,
|
||||||
|
before_data TEXT,
|
||||||
|
after_data TEXT,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
|
||||||
|
FOREIGN KEY (job_id) REFERENCES sync_jobs(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (qso_id) REFERENCES qsos(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index for faster lookups during rollback
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX idx_qso_changes_job_id ON qso_changes(job_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index for change_type lookups
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX idx_qso_changes_change_type ON qso_changes(change_type)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Migration complete! Created qso_changes table with indexes.');
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
68
src/backend/migrations/revert-dcl-entity.js
Normal file
68
src/backend/migrations/revert-dcl-entity.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Revert incorrect Germany entity assignment
|
||||||
|
*
|
||||||
|
* This script removes entity data from DCL-only QSOs that were incorrectly
|
||||||
|
* set to Germany. These QSOs should have empty entity fields since DCL
|
||||||
|
* doesn't provide DXCC data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../config.js';
|
||||||
|
import { qsos } from '../db/schema/index.js';
|
||||||
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Starting migration: Revert incorrect Germany entity assignment...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all DCL-confirmed QSOs that have entity set to Germany but NO LoTW confirmation
|
||||||
|
// These were incorrectly set by the previous migration
|
||||||
|
const dclQSOsIncorrectEntity = await db
|
||||||
|
.select()
|
||||||
|
.from(qsos)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(qsos.dclQslRstatus, 'Y'),
|
||||||
|
sql`${qsos.entity} = 'FEDERAL REPUBLIC OF GERMANY'`,
|
||||||
|
sql`(${qsos.lotwQslRstatus} IS NULL OR ${qsos.lotwQslRstatus} != 'Y')`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${dclQSOsIncorrectEntity.length} DCL-only QSOs with incorrect Germany entity`);
|
||||||
|
|
||||||
|
if (dclQSOsIncorrectEntity.length === 0) {
|
||||||
|
console.log('No QSOs need reverting. Migration complete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear entity data for these QSOs
|
||||||
|
let updated = 0;
|
||||||
|
for (const qso of dclQSOsIncorrectEntity) {
|
||||||
|
await db
|
||||||
|
.update(qsos)
|
||||||
|
.set({
|
||||||
|
entity: '',
|
||||||
|
entityId: null,
|
||||||
|
continent: '',
|
||||||
|
cqZone: null,
|
||||||
|
ituZone: null,
|
||||||
|
})
|
||||||
|
.where(eq(qsos.id, qso.id));
|
||||||
|
|
||||||
|
updated++;
|
||||||
|
if (updated % 100 === 0) {
|
||||||
|
console.log(`Reverted ${updated}/${dclQSOsIncorrectEntity.length} QSOs...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete! Reverted ${updated} QSOs to empty entity data.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate().then(() => {
|
||||||
|
console.log('Migration script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
58
src/backend/migrations/rollback-performance-indexes.js
Normal file
58
src/backend/migrations/rollback-performance-indexes.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Rollback: Remove performance indexes
|
||||||
|
*
|
||||||
|
* This script removes the performance indexes created by add-performance-indexes.js
|
||||||
|
* Use this if you need to drop the indexes for any reason.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
async function rollback() {
|
||||||
|
console.log('Starting rollback: Remove performance indexes...');
|
||||||
|
|
||||||
|
// Get the directory containing this migration file
|
||||||
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
|
const dbPath = join(__dirname, '../award.db');
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Dropping index: idx_qsos_user_band');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_user_band`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_user_mode');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_user_mode`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_user_confirmation');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_user_confirmation`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_duplicate_check');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_duplicate_check`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_lotw_confirmed');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_lotw_confirmed`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_dcl_confirmed');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_dcl_confirmed`);
|
||||||
|
|
||||||
|
console.log('Dropping index: idx_qsos_qso_date');
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_qsos_qso_date`);
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
|
||||||
|
console.log('\nRollback complete! Removed 7 performance indexes.');
|
||||||
|
console.log('\nTo verify indexes were dropped, run:');
|
||||||
|
console.log(' sqlite3 award.db ".indexes qsos"');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rollback failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run rollback
|
||||||
|
rollback().then(() => {
|
||||||
|
console.log('\nRollback script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
251
src/backend/scripts/admin-cli.js
Normal file
251
src/backend/scripts/admin-cli.js
Normal 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();
|
||||||
33
src/backend/scripts/init-db.js
Normal file
33
src/backend/scripts/init-db.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Database initialization script
|
||||||
|
* Creates the database schema using Drizzle ORM
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
|
import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
|
||||||
|
import * as schema from '../db/schema/index.js';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const dbPath = join(process.cwd(), 'src/backend/award.db');
|
||||||
|
|
||||||
|
console.log('Creating database at:', dbPath);
|
||||||
|
|
||||||
|
// Create SQLite database
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
const db = drizzle({
|
||||||
|
client: sqlite,
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Running migrations...');
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
await migrate(db, { migrationsFolder: join(process.cwd(), 'drizzle') });
|
||||||
|
|
||||||
|
console.log('✅ Database initialized successfully');
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
435
src/backend/services/admin.service.js
Normal file
435
src/backend/services/admin.service.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
373
src/backend/services/auto-sync.service.js
Normal file
373
src/backend/services/auto-sync.service.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
570
src/backend/services/awards-admin.service.js
Normal file
570
src/backend/services/awards-admin.service.js
Normal 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
249
src/backend/services/cache.service.js
Normal file
249
src/backend/services/cache.service.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Cache Service for Award Progress
|
||||||
|
*
|
||||||
|
* Provides in-memory caching for award progress calculations to avoid
|
||||||
|
* expensive database aggregations on every request.
|
||||||
|
*
|
||||||
|
* Cache TTL: 5 minutes (balances freshness with performance)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Check cache before calculating award progress
|
||||||
|
* - Invalidate cache when QSOs are synced/updated
|
||||||
|
* - Automatic expiry after TTL
|
||||||
|
*/
|
||||||
|
|
||||||
|
const awardCache = new Map();
|
||||||
|
const statsCache = new Map();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached award progress if available and not expired
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} awardId - Award ID
|
||||||
|
* @returns {object|null} Cached progress data or null if not found/expired
|
||||||
|
*/
|
||||||
|
export function getCachedAwardProgress(userId, awardId) {
|
||||||
|
const key = `${userId}:${awardId}`;
|
||||||
|
const cached = awardCache.get(key);
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
recordAwardCacheMiss();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache has expired
|
||||||
|
const age = Date.now() - cached.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
awardCache.delete(key);
|
||||||
|
recordAwardCacheMiss();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAwardCacheHit();
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set award progress in cache
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} awardId - Award ID
|
||||||
|
* @param {object} data - Award progress data to cache
|
||||||
|
*/
|
||||||
|
export function setCachedAwardProgress(userId, awardId, data) {
|
||||||
|
const key = `${userId}:${awardId}`;
|
||||||
|
awardCache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all cached awards for a specific user
|
||||||
|
* Call this after syncing or updating QSOs
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
*/
|
||||||
|
export function invalidateUserCache(userId) {
|
||||||
|
const prefix = `${userId}:`;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for (const [key] of awardCache) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
awardCache.delete(key);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached awards (use sparingly)
|
||||||
|
*主要用于测试或紧急情况
|
||||||
|
*/
|
||||||
|
export function clearAllCache() {
|
||||||
|
const size = awardCache.size;
|
||||||
|
awardCache.clear();
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired cache entries (maintenance function)
|
||||||
|
* Can be called periodically to free memory
|
||||||
|
* @returns {number} Number of entries cleaned up
|
||||||
|
*/
|
||||||
|
export function cleanupExpiredCache() {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleaned = 0;
|
||||||
|
|
||||||
|
for (const [key, value] of awardCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
awardCache.delete(key);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of statsCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
statsCache.delete(key);
|
||||||
|
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++;
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { db, logger } from '../config.js';
|
import { db, logger } from '../config.js';
|
||||||
import { qsos } from '../db/schema/index.js';
|
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, invalidateStatsCache } from './cache.service.js';
|
||||||
|
import { yieldToEventLoop, getQSOKey } from '../utils/sync-helpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DCL (DARC Community Logbook) Service
|
* DCL (DARC Community Logbook) Service
|
||||||
@@ -121,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
|
||||||
@@ -148,6 +139,7 @@ function convertQSODatabaseFormat(adifQSO, userId) {
|
|||||||
mode: normalizeMode(adifQSO.mode),
|
mode: normalizeMode(adifQSO.mode),
|
||||||
freq: adifQSO.freq ? parseInt(adifQSO.freq) : null,
|
freq: adifQSO.freq ? parseInt(adifQSO.freq) : null,
|
||||||
freqRx: adifQSO.freq_rx ? parseInt(adifQSO.freq_rx) : null,
|
freqRx: adifQSO.freq_rx ? parseInt(adifQSO.freq_rx) : null,
|
||||||
|
// DCL may or may not include DXCC fields - use them if available
|
||||||
entity: adifQSO.country || adifQSO.dxcc_country || '',
|
entity: adifQSO.country || adifQSO.dxcc_country || '',
|
||||||
entityId: adifQSO.dxcc ? parseInt(adifQSO.dxcc) : null,
|
entityId: adifQSO.dxcc ? parseInt(adifQSO.dxcc) : null,
|
||||||
grid: adifQSO.gridsquare || '',
|
grid: adifQSO.gridsquare || '',
|
||||||
@@ -168,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
|
||||||
@@ -217,106 +209,215 @@ 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;
|
||||||
.select()
|
const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
|
||||||
.from(qsos)
|
const batch = dbQSOs.slice(startIdx, endIdx);
|
||||||
.where(
|
|
||||||
and(
|
// Get unique callsigns and dates from batch
|
||||||
eq(qsos.userId, userId),
|
const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
|
||||||
eq(qsos.callsign, dbQSO.callsign),
|
const batchDates = [...new Set(batch.map(q => q.qsoDate))];
|
||||||
eq(qsos.qsoDate, dbQSO.qsoDate),
|
|
||||||
eq(qsos.timeOn, dbQSO.timeOn),
|
// Fetch all existing QSOs that could match this batch in one query
|
||||||
eq(qsos.band, dbQSO.band),
|
const existingQSOs = await db
|
||||||
eq(qsos.mode, dbQSO.mode)
|
.select()
|
||||||
)
|
.from(qsos)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(qsos.userId, userId),
|
||||||
|
// Match callsigns OR dates from this batch
|
||||||
|
sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
|
||||||
)
|
)
|
||||||
.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);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if DCL confirmation or DOK data has changed
|
// Process batch
|
||||||
const dataChanged =
|
const toInsert = [];
|
||||||
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
|
const toUpdate = [];
|
||||||
existingQSO.dclQslRdate !== dbQSO.dclQslRdate ||
|
const changeRecords = [];
|
||||||
existingQSO.darcDok !== (dbQSO.darcDok || existingQSO.darcDok) ||
|
|
||||||
existingQSO.myDarcDok !== (dbQSO.myDarcDok || existingQSO.myDarcDok) ||
|
|
||||||
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
|
|
||||||
|
|
||||||
if (dataChanged) {
|
for (const dbQSO of batch) {
|
||||||
// Update existing QSO with changed DCL confirmation and DOK data
|
try {
|
||||||
// Only update DOK/grid fields if DCL actually sent values (non-empty)
|
const key = getQSOKey(dbQSO);
|
||||||
const updateData = {
|
const existingQSO = existingMap.get(key);
|
||||||
dclQslRdate: dbQSO.dclQslRdate,
|
|
||||||
dclQslRstatus: dbQSO.dclQslRstatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only add DOK fields if DCL sent them
|
if (existingQSO) {
|
||||||
if (dbQSO.darcDok) updateData.darcDok = dbQSO.darcDok;
|
// Check if DCL confirmation or DOK data has changed
|
||||||
if (dbQSO.myDarcDok) updateData.myDarcDok = dbQSO.myDarcDok;
|
const dataChanged =
|
||||||
|
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
|
||||||
|
existingQSO.dclQslRdate !== dbQSO.dclQslRdate ||
|
||||||
|
existingQSO.darcDok !== (dbQSO.darcDok || existingQSO.darcDok) ||
|
||||||
|
existingQSO.myDarcDok !== (dbQSO.myDarcDok || existingQSO.myDarcDok) ||
|
||||||
|
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
|
||||||
|
|
||||||
// Only update grid if DCL sent one
|
if (dataChanged) {
|
||||||
if (dbQSO.grid) {
|
// Build update data
|
||||||
updateData.grid = dbQSO.grid;
|
const updateData = {
|
||||||
updateData.gridSource = dbQSO.gridSource;
|
dclQslRdate: dbQSO.dclQslRdate,
|
||||||
|
dclQslRstatus: dbQSO.dclQslRstatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only add DOK fields if DCL sent them
|
||||||
|
if (dbQSO.darcDok) updateData.darcDok = dbQSO.darcDok;
|
||||||
|
if (dbQSO.myDarcDok) updateData.myDarcDok = dbQSO.myDarcDok;
|
||||||
|
|
||||||
|
// Only update grid if DCL sent one
|
||||||
|
if (dbQSO.grid) {
|
||||||
|
updateData.grid = dbQSO.grid;
|
||||||
|
updateData.gridSource = dbQSO.gridSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (dbQSO.entity) updateData.entity = dbQSO.entity;
|
||||||
|
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
|
||||||
|
if (dbQSO.continent) updateData.continent = dbQSO.continent;
|
||||||
|
if (dbQSO.cqZone) updateData.cqZone = dbQSO.cqZone;
|
||||||
|
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
toUpdate.push({
|
||||||
|
id: existingQSO.id,
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track change for rollback
|
||||||
|
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,
|
||||||
|
dclQslRdate: dbQSO.dclQslRdate,
|
||||||
|
darcDok: updateData.darcDok,
|
||||||
|
myDarcDok: updateData.myDarcDok,
|
||||||
|
grid: updateData.grid,
|
||||||
|
gridSource: updateData.gridSource,
|
||||||
|
entity: updateData.entity,
|
||||||
|
entityId: updateData.entityId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedQSOs.push({
|
||||||
|
id: existingQSO.id,
|
||||||
|
callsign: dbQSO.callsign,
|
||||||
|
date: dbQSO.qsoDate,
|
||||||
|
band: dbQSO.band,
|
||||||
|
mode: dbQSO.mode,
|
||||||
|
});
|
||||||
|
updatedCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
await db
|
// New QSO to insert
|
||||||
.update(qsos)
|
toInsert.push(dbQSO);
|
||||||
.set(updateData)
|
addedQSOs.push({
|
||||||
.where(eq(qsos.id, existingQSO.id));
|
|
||||||
updatedCount++;
|
|
||||||
// Track updated QSO (CALL and DATE)
|
|
||||||
updatedQSOs.push({
|
|
||||||
callsign: dbQSO.callsign,
|
callsign: dbQSO.callsign,
|
||||||
date: dbQSO.qsoDate,
|
date: dbQSO.qsoDate,
|
||||||
band: dbQSO.band,
|
band: dbQSO.band,
|
||||||
mode: dbQSO.mode,
|
mode: dbQSO.mode,
|
||||||
});
|
});
|
||||||
} else {
|
addedCount++;
|
||||||
// Skip - same data
|
|
||||||
skippedCount++;
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
// Insert new QSO
|
logger.error('Failed to process DCL QSO in batch', {
|
||||||
await db.insert(qsos).values(dbQSO);
|
error: error.message,
|
||||||
addedCount++;
|
qso: dbQSO,
|
||||||
// Track added QSO (CALL and DATE)
|
userId,
|
||||||
addedQSOs.push({
|
|
||||||
callsign: dbQSO.callsign,
|
|
||||||
date: dbQSO.qsoDate,
|
|
||||||
band: dbQSO.band,
|
|
||||||
mode: dbQSO.mode,
|
|
||||||
});
|
});
|
||||||
|
errors.push({ qso: dbQSO, error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
|
||||||
logger.error('Failed to process DCL QSO', {
|
|
||||||
error: error.message,
|
|
||||||
qso: adifQSO,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
errors.push({ qso: adifQSO, 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,
|
||||||
@@ -332,6 +433,11 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
jobId,
|
jobId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate award cache for this user since QSOs may have changed
|
||||||
|
const deletedCache = invalidateUserCache(userId);
|
||||||
|
invalidateStatsCache(userId);
|
||||||
|
logger.debug(`Invalidated ${deletedCache} cached award entries and stats cache for user ${userId}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db, logger } from '../config.js';
|
import { db, logger } from '../config.js';
|
||||||
import { syncJobs } from '../db/schema/index.js';
|
import { syncJobs, qsoChanges, qsos } from '../db/schema/index.js';
|
||||||
import { eq, and, or, lt } from 'drizzle-orm';
|
import { eq, and, or, lt, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simplified Background Job Queue Service
|
* Simplified Background Job Queue Service
|
||||||
@@ -252,7 +252,7 @@ export async function getUserActiveJob(userId, jobType = null) {
|
|||||||
.select()
|
.select()
|
||||||
.from(syncJobs)
|
.from(syncJobs)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(syncJobs.createdAt)
|
.orderBy(desc(syncJobs.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return job || null;
|
return job || null;
|
||||||
@@ -269,7 +269,7 @@ export async function getUserJobs(userId, limit = 10) {
|
|||||||
.select()
|
.select()
|
||||||
.from(syncJobs)
|
.from(syncJobs)
|
||||||
.where(eq(syncJobs.userId, userId))
|
.where(eq(syncJobs.userId, userId))
|
||||||
.orderBy(syncJobs.createdAt)
|
.orderBy(desc(syncJobs.createdAt))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
return jobs.map((job) => {
|
return jobs.map((job) => {
|
||||||
@@ -342,3 +342,110 @@ export async function updateJobProgress(jobId, progressData) {
|
|||||||
result: JSON.stringify(updatedData),
|
result: JSON.stringify(updatedData),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel and rollback a sync job
|
||||||
|
* Deletes added QSOs and restores updated QSOs to their previous state
|
||||||
|
* @param {number} jobId - Job ID to cancel
|
||||||
|
* @param {number} userId - User ID (for security check)
|
||||||
|
* @returns {Promise<Object>} Result of cancellation
|
||||||
|
*/
|
||||||
|
export async function cancelJob(jobId, userId) {
|
||||||
|
logger.info('Cancelling job', { jobId, userId });
|
||||||
|
|
||||||
|
// Get job to verify ownership
|
||||||
|
const job = await getJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return { success: false, error: 'Job not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns this job
|
||||||
|
if (job.userId !== userId) {
|
||||||
|
return { success: false, error: 'Forbidden' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow cancelling failed jobs or stale running jobs
|
||||||
|
const isStale = job.status === JobStatus.RUNNING && job.startedAt &&
|
||||||
|
(Date.now() - new Date(job.startedAt).getTime()) > 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
if (job.status === JobStatus.PENDING) {
|
||||||
|
return { success: false, error: 'Cannot cancel pending jobs' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === JobStatus.COMPLETED) {
|
||||||
|
return { success: false, error: 'Cannot cancel completed jobs' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === JobStatus.RUNNING && !isStale) {
|
||||||
|
return { success: false, error: 'Cannot cancel active jobs (only stale jobs older than 1 hour)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all QSO changes for this job
|
||||||
|
const changes = await db
|
||||||
|
.select()
|
||||||
|
.from(qsoChanges)
|
||||||
|
.where(eq(qsoChanges.jobId, jobId));
|
||||||
|
|
||||||
|
let deletedAdded = 0;
|
||||||
|
let restoredUpdated = 0;
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.changeType === 'added' && change.qsoId) {
|
||||||
|
// Delete the QSO that was added
|
||||||
|
await db.delete(qsos).where(eq(qsos.id, change.qsoId));
|
||||||
|
deletedAdded++;
|
||||||
|
} else if (change.changeType === 'updated' && change.qsoId && change.beforeData) {
|
||||||
|
// Restore the QSO to its previous state
|
||||||
|
try {
|
||||||
|
const beforeData = JSON.parse(change.beforeData);
|
||||||
|
|
||||||
|
// Build update object based on job type
|
||||||
|
const updateData = {};
|
||||||
|
|
||||||
|
if (job.type === 'lotw_sync') {
|
||||||
|
if (beforeData.lotwQslRstatus !== undefined) updateData.lotwQslRstatus = beforeData.lotwQslRstatus;
|
||||||
|
if (beforeData.lotwQslRdate !== undefined) updateData.lotwQslRdate = beforeData.lotwQslRdate;
|
||||||
|
} else if (job.type === 'dcl_sync') {
|
||||||
|
if (beforeData.dclQslRstatus !== undefined) updateData.dclQslRstatus = beforeData.dclQslRstatus;
|
||||||
|
if (beforeData.dclQslRdate !== undefined) updateData.dclQslRdate = beforeData.dclQslRdate;
|
||||||
|
if (beforeData.darcDok !== undefined) updateData.darcDok = beforeData.darcDok;
|
||||||
|
if (beforeData.myDarcDok !== undefined) updateData.myDarcDok = beforeData.myDarcDok;
|
||||||
|
if (beforeData.grid !== undefined) updateData.grid = beforeData.grid;
|
||||||
|
if (beforeData.gridSource !== undefined) updateData.gridSource = beforeData.gridSource;
|
||||||
|
if (beforeData.entity !== undefined) updateData.entity = beforeData.entity;
|
||||||
|
if (beforeData.entityId !== undefined) updateData.entityId = beforeData.entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await db.update(qsos).set(updateData).where(eq(qsos.id, change.qsoId));
|
||||||
|
restoredUpdated++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to restore QSO', { qsoId: change.qsoId, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all change records for this job
|
||||||
|
await db.delete(qsoChanges).where(eq(qsoChanges.jobId, jobId));
|
||||||
|
|
||||||
|
// Update job status to cancelled
|
||||||
|
await updateJob(jobId, {
|
||||||
|
status: 'cancelled',
|
||||||
|
completedAt: new Date(),
|
||||||
|
result: JSON.stringify({
|
||||||
|
cancelled: true,
|
||||||
|
deletedAdded,
|
||||||
|
restoredUpdated,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Job cancelled successfully', { jobId, deletedAdded, restoredUpdated });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Job cancelled: ${deletedAdded} QSOs deleted, ${restoredUpdated} QSOs restored`,
|
||||||
|
deletedAdded,
|
||||||
|
restoredUpdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { db, logger } from '../config.js';
|
import { db, logger } from '../config.js';
|
||||||
import { qsos } from '../db/schema/index.js';
|
import { qsos, qsoChanges, syncJobs, awardProgress } from '../db/schema/index.js';
|
||||||
import { max, sql, eq, and, desc } 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, 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
|
||||||
@@ -14,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
|
||||||
*/
|
*/
|
||||||
@@ -50,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({
|
||||||
@@ -145,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.`
|
||||||
};
|
};
|
||||||
@@ -180,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
|
||||||
@@ -227,85 +260,177 @@ 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++) {
|
||||||
.select()
|
const startIdx = batchNum * BATCH_SIZE;
|
||||||
.from(qsos)
|
const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
|
||||||
.where(
|
const batch = dbQSOs.slice(startIdx, endIdx);
|
||||||
and(
|
|
||||||
eq(qsos.userId, userId),
|
// Build condition for batch duplicate check
|
||||||
eq(qsos.callsign, dbQSO.callsign),
|
// Get unique callsigns, dates, bands, modes from batch
|
||||||
eq(qsos.qsoDate, dbQSO.qsoDate),
|
const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
|
||||||
eq(qsos.band, dbQSO.band),
|
const batchDates = [...new Set(batch.map(q => q.qsoDate))];
|
||||||
eq(qsos.mode, dbQSO.mode)
|
|
||||||
)
|
// Fetch all existing QSOs that could match this batch in one query
|
||||||
|
const existingQSOs = await db
|
||||||
|
.select()
|
||||||
|
.from(qsos)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(qsos.userId, userId),
|
||||||
|
// Match callsigns OR dates from this batch
|
||||||
|
sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
|
||||||
)
|
)
|
||||||
.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);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if LoTW confirmation data has changed
|
// Process batch
|
||||||
const confirmationChanged =
|
const toInsert = [];
|
||||||
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus ||
|
const toUpdate = [];
|
||||||
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate;
|
const changeRecords = [];
|
||||||
|
|
||||||
if (confirmationChanged) {
|
for (const dbQSO of batch) {
|
||||||
await db
|
try {
|
||||||
.update(qsos)
|
const key = getQSOKey(dbQSO);
|
||||||
.set({
|
const existingQSO = existingMap.get(key);
|
||||||
|
|
||||||
|
if (existingQSO) {
|
||||||
|
// Check if LoTW confirmation data has changed
|
||||||
|
const confirmationChanged =
|
||||||
|
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus ||
|
||||||
|
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate;
|
||||||
|
|
||||||
|
if (confirmationChanged) {
|
||||||
|
toUpdate.push({
|
||||||
|
id: existingQSO.id,
|
||||||
lotwQslRdate: dbQSO.lotwQslRdate,
|
lotwQslRdate: dbQSO.lotwQslRdate,
|
||||||
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
||||||
lotwSyncedAt: dbQSO.lotwSyncedAt,
|
lotwSyncedAt: dbQSO.lotwSyncedAt,
|
||||||
})
|
});
|
||||||
.where(eq(qsos.id, existingQSO.id));
|
|
||||||
updatedCount++;
|
// Track change for rollback
|
||||||
// Track updated QSO (CALL and DATE)
|
if (jobId) {
|
||||||
updatedQSOs.push({
|
changeRecords.push({
|
||||||
|
jobId,
|
||||||
|
qsoId: existingQSO.id,
|
||||||
|
changeType: 'updated',
|
||||||
|
beforeData: JSON.stringify({
|
||||||
|
lotwQslRstatus: existingQSO.lotwQslRstatus,
|
||||||
|
lotwQslRdate: existingQSO.lotwQslRdate,
|
||||||
|
}),
|
||||||
|
afterData: JSON.stringify({
|
||||||
|
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
||||||
|
lotwQslRdate: dbQSO.lotwQslRdate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedQSOs.push({
|
||||||
|
id: existingQSO.id,
|
||||||
|
callsign: dbQSO.callsign,
|
||||||
|
date: dbQSO.qsoDate,
|
||||||
|
band: dbQSO.band,
|
||||||
|
mode: dbQSO.mode,
|
||||||
|
});
|
||||||
|
updatedCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New QSO to insert
|
||||||
|
toInsert.push(dbQSO);
|
||||||
|
addedQSOs.push({
|
||||||
callsign: dbQSO.callsign,
|
callsign: dbQSO.callsign,
|
||||||
date: dbQSO.qsoDate,
|
date: dbQSO.qsoDate,
|
||||||
band: dbQSO.band,
|
band: dbQSO.band,
|
||||||
mode: dbQSO.mode,
|
mode: dbQSO.mode,
|
||||||
});
|
});
|
||||||
} else {
|
addedCount++;
|
||||||
// Skip - same data
|
|
||||||
skippedCount++;
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
await db.insert(qsos).values(dbQSO);
|
logger.error('Error processing QSO in batch', { error: error.message, jobId, qso: dbQSO });
|
||||||
addedCount++;
|
errors.push({ qso: dbQSO, error: error.message });
|
||||||
// Track added QSO (CALL and DATE)
|
|
||||||
addedQSOs.push({
|
|
||||||
callsign: dbQSO.callsign,
|
|
||||||
date: dbQSO.qsoDate,
|
|
||||||
band: dbQSO.band,
|
|
||||||
mode: dbQSO.mode,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
|
||||||
logger.error('Error processing QSO', { error: error.message, jobId, qso: qsoData });
|
|
||||||
errors.push({ qso: qsoData, 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({
|
||||||
|
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: adifQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
|
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);
|
||||||
|
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,
|
||||||
@@ -321,14 +446,69 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
export async function getUserQSOs(userId, filters = {}, options = {}) {
|
export async function getUserQSOs(userId, filters = {}, options = {}) {
|
||||||
const { page = 1, limit = 100 } = options;
|
const { page = 1, limit = 100 } = options;
|
||||||
|
|
||||||
|
logger.debug('getUserQSOs called', { userId, filters, options });
|
||||||
|
|
||||||
const conditions = [eq(qsos.userId, userId)];
|
const conditions = [eq(qsos.userId, userId)];
|
||||||
|
|
||||||
if (filters.band) conditions.push(eq(qsos.band, filters.band));
|
if (filters.band) conditions.push(eq(qsos.band, filters.band));
|
||||||
if (filters.mode) conditions.push(eq(qsos.mode, filters.mode));
|
if (filters.mode) conditions.push(eq(qsos.mode, filters.mode));
|
||||||
if (filters.confirmed) conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
if (filters.confirmed) conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||||
|
|
||||||
const allResults = await db.select().from(qsos).where(and(...conditions));
|
// Confirmation type filter: lotw, dcl, both, none
|
||||||
const totalCount = allResults.length;
|
if (filters.confirmationType) {
|
||||||
|
logger.debug('Applying confirmation type filter', { confirmationType: filters.confirmationType });
|
||||||
|
if (filters.confirmationType === 'lotw') {
|
||||||
|
// LoTW only: Confirmed by LoTW but NOT by DCL
|
||||||
|
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||||
|
conditions.push(
|
||||||
|
sql`(${qsos.dclQslRstatus} IS NULL OR ${qsos.dclQslRstatus} != 'Y')`
|
||||||
|
);
|
||||||
|
} else if (filters.confirmationType === 'dcl') {
|
||||||
|
// DCL only: Confirmed by DCL but NOT by LoTW
|
||||||
|
conditions.push(eq(qsos.dclQslRstatus, 'Y'));
|
||||||
|
conditions.push(
|
||||||
|
sql`(${qsos.lotwQslRstatus} IS NULL OR ${qsos.lotwQslRstatus} != 'Y')`
|
||||||
|
);
|
||||||
|
} else if (filters.confirmationType === 'both') {
|
||||||
|
// Both confirmed: Confirmed by LoTW AND DCL
|
||||||
|
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||||
|
conditions.push(eq(qsos.dclQslRstatus, 'Y'));
|
||||||
|
} else if (filters.confirmationType === 'any') {
|
||||||
|
// Confirmed by at least 1 service: LoTW OR DCL
|
||||||
|
conditions.push(
|
||||||
|
sql`(${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y')`
|
||||||
|
);
|
||||||
|
} else if (filters.confirmationType === 'none') {
|
||||||
|
// Not confirmed: Not confirmed by LoTW AND not confirmed by DCL
|
||||||
|
conditions.push(
|
||||||
|
sql`(${qsos.lotwQslRstatus} IS NULL OR ${qsos.lotwQslRstatus} != 'Y')`
|
||||||
|
);
|
||||||
|
conditions.push(
|
||||||
|
sql`(${qsos.dclQslRstatus} IS NULL OR ${qsos.dclQslRstatus} != 'Y')`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter: callsign, entity, or grid
|
||||||
|
if (filters.search) {
|
||||||
|
// SECURITY: Sanitize search input to prevent injection
|
||||||
|
const sanitized = sanitizeSearchInput(filters.search);
|
||||||
|
if (sanitized) {
|
||||||
|
const searchTerm = `%${sanitized}%`;
|
||||||
|
conditions.push(or(
|
||||||
|
like(qsos.callsign, searchTerm),
|
||||||
|
like(qsos.entity, searchTerm),
|
||||||
|
like(qsos.grid, searchTerm)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql`CAST(count(*) AS INTEGER)` })
|
||||||
|
.from(qsos)
|
||||||
|
.where(and(...conditions));
|
||||||
|
const totalCount = count;
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
@@ -357,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');
|
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 {
|
||||||
|
total: basicStats[0].total,
|
||||||
|
confirmed: basicStats[0].confirmed || 0,
|
||||||
|
uniqueEntities: uniqueStats[0].uniqueEntities || 0,
|
||||||
|
uniqueBands: uniqueStats[0].uniqueBands || 0,
|
||||||
|
uniqueModes: uniqueStats[0].uniqueModes || 0,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
// Cache results
|
||||||
total: allQSOs.length,
|
setCachedStats(userId, stats);
|
||||||
confirmed: confirmed.length,
|
|
||||||
uniqueEntities: uniqueEntities.size,
|
return stats;
|
||||||
uniqueBands: uniqueBands.size,
|
|
||||||
uniqueModes: uniqueModes.size,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -402,9 +596,72 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single QSO by ID for a specific user
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {number} qsoId - QSO ID
|
||||||
|
* @returns {Object|null} QSO object or null if not found
|
||||||
|
*/
|
||||||
|
export async function getQSOById(userId, qsoId) {
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(qsos)
|
||||||
|
.where(and(eq(qsos.userId, userId), eq(qsos.id, qsoId)));
|
||||||
|
|
||||||
|
return result.length > 0 ? result[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
274
src/backend/services/performance.service.js
Normal file
274
src/backend/services/performance.service.js
Normal 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
234
src/backend/services/scheduler.service.js
Normal file
234
src/backend/services/scheduler.service.js
Normal 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();
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
*/
|
*/
|
||||||
export function parseADIF(adifData) {
|
export function parseADIF(adifData) {
|
||||||
const qsos = [];
|
const qsos = [];
|
||||||
// Split by <EOR> (end of record) - case sensitive as per ADIF spec
|
|
||||||
const records = adifData.split('<EOR>');
|
// Split by <EOR> (case-insensitive to handle <EOR>, <eor>, <Eor>, etc.)
|
||||||
|
const regex = new RegExp('<eor>', 'gi');
|
||||||
|
const records = adifData.split(regex);
|
||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (!record.trim()) continue;
|
if (!record.trim()) continue;
|
||||||
@@ -26,10 +28,11 @@ export function parseADIF(adifData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const qso = {};
|
const qso = {};
|
||||||
const regex = /<([A-Z0-9_]+):(\d+)(?::[A-Z]+)?>/gi;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = regex.exec(record)) !== null) {
|
// Use matchAll for cleaner parsing (creates new iterator for each record)
|
||||||
|
const matches = record.matchAll(/<([A-Z0-9_]+):(\d+)(?::[A-Z]+)?>/gi);
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
const [fullMatch, fieldName, lengthStr] = match;
|
const [fullMatch, fieldName, lengthStr] = match;
|
||||||
const length = parseInt(lengthStr, 10);
|
const length = parseInt(lengthStr, 10);
|
||||||
const valueStart = match.index + fullMatch.length;
|
const valueStart = match.index + fullMatch.length;
|
||||||
@@ -38,9 +41,6 @@ export function parseADIF(adifData) {
|
|||||||
const value = record.substring(valueStart, valueStart + length);
|
const value = record.substring(valueStart, valueStart + length);
|
||||||
|
|
||||||
qso[fieldName.toLowerCase()] = value.trim();
|
qso[fieldName.toLowerCase()] = value.trim();
|
||||||
|
|
||||||
// Update regex position to continue after the value
|
|
||||||
regex.lastIndex = valueStart + length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only add if we have at least a callsign
|
// Only add if we have at least a callsign
|
||||||
|
|||||||
23
src/backend/utils/sync-helpers.js
Normal file
23
src/backend/utils/sync-helpers.js
Normal 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
186
src/frontend/src/app.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -84,4 +84,75 @@ export const jobsAPI = {
|
|||||||
getStatus: (jobId) => apiRequest(`/jobs/${jobId}`),
|
getStatus: (jobId) => apiRequest(`/jobs/${jobId}`),
|
||||||
getActive: () => apiRequest('/jobs/active'),
|
getActive: () => apiRequest('/jobs/active'),
|
||||||
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
||||||
|
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 }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
152
src/frontend/src/lib/components/ThemeSwitcher.svelte
Normal file
152
src/frontend/src/lib/components/ThemeSwitcher.svelte
Normal 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>
|
||||||
192
src/frontend/src/lib/logger.js
Normal file
192
src/frontend/src/lib/logger.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Frontend Logger
|
||||||
|
*
|
||||||
|
* Sends logs to backend endpoint which writes to logs/frontend.log
|
||||||
|
* Respects LOG_LEVEL environment variable from backend
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { logger } from '$lib/logger';
|
||||||
|
* logger.info('User logged in', { userId: 123 });
|
||||||
|
* logger.error('Failed to fetch data', { error: err.message });
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Log levels matching backend
|
||||||
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
|
|
||||||
|
// Get log level from backend or default to info
|
||||||
|
let currentLogLevel = LOG_LEVELS.info;
|
||||||
|
|
||||||
|
// Buffer for batching logs (sends when buffer reaches this size or after timeout)
|
||||||
|
const logBuffer = [];
|
||||||
|
const BUFFER_SIZE = 10;
|
||||||
|
const BUFFER_TIMEOUT = 5000; // 5 seconds
|
||||||
|
let bufferTimeout = null;
|
||||||
|
|
||||||
|
// Fetch current log level from backend on initialization
|
||||||
|
async function fetchLogLevel() {
|
||||||
|
try {
|
||||||
|
// Try to get log level from health endpoint or localStorage
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
if (response.ok) {
|
||||||
|
// For now, we'll assume the backend doesn't expose log level in health
|
||||||
|
// Could add it later. Default to info in production, debug in development
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
currentLogLevel = isDev ? LOG_LEVELS.debug : LOG_LEVELS.info;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Default to info if can't fetch
|
||||||
|
currentLogLevel = LOG_LEVELS.info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize log level
|
||||||
|
fetchLogLevel();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send logs to backend
|
||||||
|
*/
|
||||||
|
async function sendLogs(entries) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include', // Include cookies for authentication
|
||||||
|
body: JSON.stringify(entries),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Silent fail - don't break the app if logging fails
|
||||||
|
console.error('Failed to send logs to backend:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush log buffer
|
||||||
|
*/
|
||||||
|
function flushBuffer() {
|
||||||
|
if (logBuffer.length === 0) return;
|
||||||
|
|
||||||
|
const entries = [...logBuffer];
|
||||||
|
logBuffer.length = 0; // Clear buffer
|
||||||
|
|
||||||
|
if (bufferTimeout) {
|
||||||
|
clearTimeout(bufferTimeout);
|
||||||
|
bufferTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLogs(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add log entry to buffer
|
||||||
|
*/
|
||||||
|
function addToBuffer(level, message, data) {
|
||||||
|
// Check if we should log this level
|
||||||
|
if (LOG_LEVELS[level] < currentLogLevel) return;
|
||||||
|
|
||||||
|
logBuffer.push({
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
data: data || undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush if buffer is full
|
||||||
|
if (logBuffer.length >= BUFFER_SIZE) {
|
||||||
|
flushBuffer();
|
||||||
|
} else {
|
||||||
|
// Set timeout to flush after BUFFER_TIMEOUT
|
||||||
|
if (bufferTimeout) {
|
||||||
|
clearTimeout(bufferTimeout);
|
||||||
|
}
|
||||||
|
bufferTimeout = setTimeout(flushBuffer, BUFFER_TIMEOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger API
|
||||||
|
*/
|
||||||
|
export const logger = {
|
||||||
|
/**
|
||||||
|
* Log debug message
|
||||||
|
*/
|
||||||
|
debug: (message, data) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.debug('[DEBUG]', message, data || '');
|
||||||
|
}
|
||||||
|
addToBuffer('debug', message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log info message
|
||||||
|
*/
|
||||||
|
info: (message, data) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[INFO]', message, data || '');
|
||||||
|
}
|
||||||
|
addToBuffer('info', message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log warning message
|
||||||
|
*/
|
||||||
|
warn: (message, data) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[WARN]', message, data || '');
|
||||||
|
}
|
||||||
|
addToBuffer('warn', message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error message
|
||||||
|
*/
|
||||||
|
error: (message, data) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('[ERROR]', message, data || '');
|
||||||
|
}
|
||||||
|
addToBuffer('error', message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately flush the log buffer
|
||||||
|
*/
|
||||||
|
flush: flushBuffer,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the log level (for testing purposes)
|
||||||
|
*/
|
||||||
|
setLogLevel: (level) => {
|
||||||
|
if (LOG_LEVELS[level] !== undefined) {
|
||||||
|
currentLogLevel = LOG_LEVELS[level];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SvelteKit action for automatic error logging
|
||||||
|
* Can be used in +page.svelte or +layout.svelte
|
||||||
|
*/
|
||||||
|
export function setupErrorLogging() {
|
||||||
|
// Log unhandled errors
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
logger.error('Unhandled error', {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
error: event.error?.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
logger.error('Unhandled promise rejection', {
|
||||||
|
reason: event.reason,
|
||||||
|
promise: event.promise?.toString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default logger;
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
src/frontend/src/lib/stores/theme.js
Normal file
56
src/frontend/src/lib/stores/theme.js
Normal 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();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,14 +1,249 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
|
import { jobsAPI, autoSyncAPI } from '$lib/api.js';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
onMount(() => {
|
let jobs = [];
|
||||||
|
let loading = true;
|
||||||
|
let cancellingJobs = new Map(); // Track cancelling state per job
|
||||||
|
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() {
|
||||||
|
try {
|
||||||
|
const response = await jobsAPI.getRecent(5);
|
||||||
|
jobs = response.jobs || [];
|
||||||
|
|
||||||
|
// Check if we need to update polling state
|
||||||
|
await tick();
|
||||||
|
updatePollingState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load jobs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
return jobs.some(job => job.status === 'pending' || job.status === 'running');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePollingState() {
|
||||||
|
if (hasActiveJobs()) {
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollingInterval) return; // Already polling
|
||||||
|
|
||||||
|
pollingInterval = setInterval(async () => {
|
||||||
|
await loadJobs();
|
||||||
|
}, 2000); // Poll every 2 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
pollingInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
// Load user profile on mount if we have a token
|
// Load user profile on mount if we have a token
|
||||||
if (browser) {
|
if (browser) {
|
||||||
auth.loadProfile();
|
auth.loadProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load recent jobs if authenticated
|
||||||
|
if ($auth.user) {
|
||||||
|
await loadJobs();
|
||||||
|
await loadAutoSyncSettings();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cancelJob(jobId) {
|
||||||
|
if (!confirm('Are you sure you want to cancel this job? This will rollback all changes made by this sync.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellingJobs.set(jobId, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await jobsAPI.cancel(jobId);
|
||||||
|
alert(result.message || 'Job cancelled successfully');
|
||||||
|
// Reload jobs to show updated status
|
||||||
|
await loadJobs();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to cancel job: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
cancellingJobs.delete(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancelJob(job) {
|
||||||
|
// Only allow cancelling failed jobs or stale running jobs
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow cancelling stale running jobs (>1 hour)
|
||||||
|
if (job.status === 'running' && job.startedAt) {
|
||||||
|
const started = new Date(job.startedAt);
|
||||||
|
const now = new Date();
|
||||||
|
const hoursSinceStart = (now - started) / (1000 * 60 * 60);
|
||||||
|
return hoursSinceStart > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJobStale(job) {
|
||||||
|
return job.status === 'running' && job.startedAt &&
|
||||||
|
(new Date() - new Date(job.startedAt)) > (1000 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobIcon(type) {
|
||||||
|
return type === 'lotw_sync' ? '📡' : '🛰️';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobLabel(type) {
|
||||||
|
return type === 'lotw_sync' ? 'LoTW Sync' : 'DCL Sync';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const styles = {
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
running: 'bg-blue-100 text-blue-800',
|
||||||
|
completed: 'bg-green-100 text-green-800',
|
||||||
|
failed: 'bg-red-100 text-red-800',
|
||||||
|
cancelled: 'bg-purple-100 text-purple-800',
|
||||||
|
};
|
||||||
|
return styles[status] || 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
if (!timestamp) return '-';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return '-';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDuration(job) {
|
||||||
|
if (!job.startedAt || !job.completedAt) return null;
|
||||||
|
const diff = new Date(job.completedAt) - new Date(job.startedAt);
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -40,6 +275,140 @@
|
|||||||
</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 -->
|
||||||
|
<div class="jobs-section">
|
||||||
|
<h2 class="section-title">🔄 Recent Sync Jobs</h2>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-state">Loading jobs...</div>
|
||||||
|
{:else if jobs.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No sync jobs yet. Sync your QSOs from LoTW or DCL to get started!</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a href="/settings" class="btn btn-secondary">Configure Credentials</a>
|
||||||
|
<a href="/qsos" class="btn btn-primary">Sync QSOs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="jobs-list">
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<div class="job-card" class:failed={job.status === 'failed'}>
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">
|
||||||
|
<span class="job-icon">{getJobIcon(job.type)}</span>
|
||||||
|
<span class="job-name">{getJobLabel(job.type)}</span>
|
||||||
|
<span class="job-id">#{job.id}</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge {getStatusBadge(job.status)}">
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-meta">
|
||||||
|
<span class="job-date" title={new Date(job.createdAt).toLocaleString()}>
|
||||||
|
{formatDate(job.createdAt)}
|
||||||
|
</span>
|
||||||
|
{#if job.startedAt}
|
||||||
|
<span class="job-time">{formatTime(job.startedAt)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if getDuration(job)}
|
||||||
|
<span class="job-duration">({getDuration(job)})</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if job.status === 'failed' && job.error}
|
||||||
|
<div class="job-error">
|
||||||
|
❌ {job.error}
|
||||||
|
</div>
|
||||||
|
{:else if job.result}
|
||||||
|
<div class="job-stats">
|
||||||
|
{#if job.result.total !== undefined}
|
||||||
|
<span class="stat-item">
|
||||||
|
<strong>{job.result.total}</strong> total
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if job.result.added !== undefined && job.result.added > 0}
|
||||||
|
<span class="stat-item stat-added">
|
||||||
|
+{job.result.added} added
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if job.result.updated !== undefined && job.result.updated > 0}
|
||||||
|
<span class="stat-item stat-updated">
|
||||||
|
~{job.result.updated} updated
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if job.result.skipped !== undefined && job.result.skipped > 0}
|
||||||
|
<span class="stat-item stat-skipped">
|
||||||
|
{job.result.skipped} skipped
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if job.status === 'running' || job.status === 'pending'}
|
||||||
|
<div class="job-progress">
|
||||||
|
<span class="progress-text">
|
||||||
|
{job.status === 'pending' ? 'Waiting to start...' : isJobStale(job) ? 'Stale - no progress for over 1 hour' : 'Processing...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Cancel button for eligible jobs -->
|
||||||
|
{#if canCancelJob(job)}
|
||||||
|
<div class="job-actions">
|
||||||
|
<button
|
||||||
|
class="btn-cancel"
|
||||||
|
disabled={cancellingJobs.get(job.id)}
|
||||||
|
on:click|stopPropagation={() => cancelJob(job.id)}
|
||||||
|
>
|
||||||
|
{cancellingJobs.get(job.id) ? 'Cancelling...' : 'Cancel & Rollback'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h3>Getting Started</h3>
|
<h3>Getting Started</h3>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -76,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,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;
|
||||||
}
|
}
|
||||||
@@ -113,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,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;
|
||||||
@@ -144,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;
|
||||||
@@ -170,25 +539,321 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Jobs Section */
|
||||||
|
.jobs-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card.failed {
|
||||||
|
border-left: 4px solid var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card-scheduled {
|
||||||
|
border-left: 4px solid var(--badge-purple-bg);
|
||||||
|
background: var(--gradient-scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-pill);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-yellow-100 {
|
||||||
|
background-color: var(--badge-pending-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-blue-100 {
|
||||||
|
background-color: var(--badge-running-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-green-100 {
|
||||||
|
background-color: var(--badge-completed-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-red-100 {
|
||||||
|
background-color: var(--badge-failed-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-yellow-800 {
|
||||||
|
color: var(--badge-pending-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-blue-800 {
|
||||||
|
color: var(--badge-running-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green-800 {
|
||||||
|
color: var(--badge-completed-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red-800 {
|
||||||
|
color: var(--badge-failed-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-purple-100 {
|
||||||
|
background-color: var(--badge-cancelled-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-purple-800 {
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-date {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-time,
|
||||||
|
.job-duration {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-error {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-text);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-added {
|
||||||
|
color: var(--color-success);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-updated {
|
||||||
|
color: var(--color-info);
|
||||||
|
background: var(--color-info-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-skipped {
|
||||||
|
color: var(--badge-pending-text);
|
||||||
|
background: var(--badge-pending-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
color: var(--color-info);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-actions {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--color-error);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
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>
|
||||||
|
|||||||
1035
src/frontend/src/routes/admin/+page.svelte
Normal file
1035
src/frontend/src/routes/admin/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
381
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
381
src/frontend/src/routes/admin/awards/+page.svelte
Normal 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>
|
||||||
1516
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
1516
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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>
|
||||||
1556
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
1556
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
|
|
||||||
|
let allAwards = [];
|
||||||
let awards = [];
|
let awards = [];
|
||||||
|
let categories = [];
|
||||||
|
let selectedCategory = 'all';
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
@@ -16,69 +19,94 @@
|
|||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
// Get awards from API
|
// Get awards from API
|
||||||
const response = await fetch('/api/awards', {
|
const awardsResponse = await fetch('/api/awards', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
'Authorization': `Bearer ${$auth.token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!awardsResponse.ok) {
|
||||||
throw new Error('Failed to load awards');
|
throw new Error('Failed to load awards');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const awardsData = await awardsResponse.json();
|
||||||
|
|
||||||
if (!data.success) {
|
if (!awardsData.success) {
|
||||||
throw new Error(data.error || 'Failed to load awards');
|
throw new Error(awardsData.error || 'Failed to load awards');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load progress for each award
|
// Get progress for all awards in a single batch request (fixes N+1 problem)
|
||||||
awards = await Promise.all(
|
const progressResponse = await fetch('/api/awards/batch/progress', {
|
||||||
data.awards.map(async (award) => {
|
headers: {
|
||||||
try {
|
'Authorization': `Bearer ${$auth.token}`,
|
||||||
const progressResponse = await fetch(`/api/awards/${award.id}/progress`, {
|
},
|
||||||
headers: {
|
});
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (progressResponse.ok) {
|
let progressMap = {};
|
||||||
const progressData = await progressResponse.json();
|
if (progressResponse.ok) {
|
||||||
if (progressData.success) {
|
const progressData = await progressResponse.json();
|
||||||
return {
|
if (progressData.success && progressData.awards) {
|
||||||
...award,
|
// Create a map of awardId -> progress for quick lookup
|
||||||
progress: progressData,
|
progressMap = Object.fromEntries(
|
||||||
};
|
progressData.awards.map(p => [p.awardId, p])
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}
|
||||||
console.error(`Failed to load progress for ${award.id}:`, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return award without progress if it failed
|
// Combine awards with their progress
|
||||||
return {
|
allAwards = awardsData.awards.map(award => ({
|
||||||
...award,
|
...award,
|
||||||
progress: {
|
progress: progressMap[award.id] || {
|
||||||
worked: 0,
|
worked: 0,
|
||||||
confirmed: 0,
|
confirmed: 0,
|
||||||
target: award.rules?.target || 0,
|
target: award.rules?.target || 0,
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
})
|
|
||||||
);
|
// Extract unique categories
|
||||||
|
categories = ['all', ...new Set(allAwards.map(a => a.category).filter(Boolean))];
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
applyFilter();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
if (selectedCategory === 'all') {
|
||||||
|
awards = allAwards;
|
||||||
|
} else {
|
||||||
|
awards = allAwards.filter(award => award.category === selectedCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(event) {
|
||||||
|
selectedCategory = event.target.value;
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Awards</h1>
|
<h1>Awards</h1>
|
||||||
<p class="subtitle">Track your ham radio award progress</p>
|
<p class="subtitle">Track your ham radio award progress</p>
|
||||||
|
|
||||||
|
{#if !loading && awards.length > 0}
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="category-filter">Category:</label>
|
||||||
|
<select id="category-filter" value={selectedCategory} on:change={onCategoryChange}>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category}>{category === 'all' ? 'All Awards' : category.toUpperCase()}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading awards...</div>
|
<div class="loading">Loading awards...</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@@ -145,34 +173,74 @@
|
|||||||
|
|
||||||
<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 150px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error,
|
.error,
|
||||||
.empty {
|
.empty {
|
||||||
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 {
|
||||||
@@ -182,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;
|
||||||
}
|
}
|
||||||
@@ -201,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;
|
||||||
@@ -218,7 +286,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
@@ -228,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 {
|
||||||
@@ -240,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,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,
|
||||||
@@ -290,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;
|
||||||
@@ -312,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
File diff suppressed because it is too large
Load Diff
52
src/frontend/src/routes/qsos/components/QSOStats.svelte
Normal file
52
src/frontend/src/routes/qsos/components/QSOStats.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script>
|
||||||
|
export let stats;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if stats}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{stats.total}</div>
|
||||||
|
<div class="stat-label">Total QSOs</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{stats.confirmed}</div>
|
||||||
|
<div class="stat-label">Confirmed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{stats.uniqueEntities}</div>
|
||||||
|
<div class="stat-label">DXCC Entities</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{stats.uniqueBands}</div>
|
||||||
|
<div class="stat-label">Bands</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
src/frontend/src/routes/qsos/components/SyncButton.svelte
Normal file
59
src/frontend/src/routes/qsos/components/SyncButton.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script>
|
||||||
|
export let service = 'lotw'; // 'lotw' or 'dcl'
|
||||||
|
export let syncStatus = null;
|
||||||
|
export let deleting = false;
|
||||||
|
export let onSync = () => {};
|
||||||
|
|
||||||
|
$: isRunning = syncStatus === 'running' || syncStatus === 'pending';
|
||||||
|
$: buttonClass = service === 'lotw' ? 'lotw-btn' : 'dcl-btn';
|
||||||
|
$: label = service === 'lotw' ? 'LoTW' : 'DCL';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary {buttonClass}"
|
||||||
|
on:click={onSync}
|
||||||
|
disabled={isRunning || deleting}
|
||||||
|
>
|
||||||
|
{#if isRunning}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
{label} Syncing...
|
||||||
|
{:else}
|
||||||
|
Sync from {label}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lotw-btn {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lotw-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-btn {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-btn:hover:not(:disabled) {
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,29 +5,42 @@ import { defineConfig } from 'vite';
|
|||||||
function suppressURIErrorPlugin() {
|
function suppressURIErrorPlugin() {
|
||||||
return {
|
return {
|
||||||
name: 'suppress-uri-error',
|
name: 'suppress-uri-error',
|
||||||
|
enforce: 'pre', // Run this plugin before others
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, res, next) => {
|
// Return a function that will be called after all plugins are configured
|
||||||
// Intercept malformed requests before they reach Vite's middleware
|
// This ensures our middleware is added at the correct time
|
||||||
try {
|
return () => {
|
||||||
// Try to decode the URL to catch malformed URIs early
|
// Add middleware BEFORE all other middlewares
|
||||||
if (req.url) {
|
// We insert it at position 0 to ensure it runs first
|
||||||
decodeURI(req.url);
|
server.middlewares.stack.unshift({
|
||||||
|
route: '',
|
||||||
|
handle: (req, res, next) => {
|
||||||
|
// Intercept malformed requests before they reach SvelteKit
|
||||||
|
try {
|
||||||
|
// Try to decode the URL to catch malformed URIs early
|
||||||
|
if (req.url) {
|
||||||
|
decodeURI(req.url);
|
||||||
|
// Also try the full URL construction that SvelteKit does
|
||||||
|
const base = `${server.config.server.https ? 'https' : 'http'}://${
|
||||||
|
req.headers[':authority'] || req.headers.host || 'localhost'
|
||||||
|
}`;
|
||||||
|
decodeURI(new URL(base + req.url).pathname);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore malformed URIs from browser extensions
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
next();
|
||||||
// Silently ignore malformed URIs from browser extensions
|
}});
|
||||||
// Don't call next(), just end the response
|
};
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('OK');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), suppressURIErrorPlugin()],
|
plugins: [suppressURIErrorPlugin(), sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
1
start.sh
1
start.sh
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user