Compare commits
34 Commits
docker
...
a5f0e3b96f
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
@@ -1,61 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
# Note: bun.lock is needed by Dockerfile for --frozen-lockfile
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Database - will be in volume mount
|
|
||||||
**/*.db
|
|
||||||
**/*.db-shm
|
|
||||||
**/*.db-wal
|
|
||||||
|
|
||||||
# Build outputs - built in container
|
|
||||||
src/frontend/build/
|
|
||||||
src/frontend/.svelte-kit/
|
|
||||||
src/frontend/dist/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Documentation (keep docs in image but don't need in build context)
|
|
||||||
# README.md
|
|
||||||
docs/
|
|
||||||
*.md
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs/
|
|
||||||
*.log
|
|
||||||
backend.log
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
*.test.js
|
|
||||||
*.test.ts
|
|
||||||
coverage/
|
|
||||||
|
|
||||||
# Docker files
|
|
||||||
Dockerfile
|
|
||||||
docker-compose.yml
|
|
||||||
.dockerignore
|
|
||||||
|
|
||||||
# CI/CD
|
|
||||||
.github/
|
|
||||||
.gitlab-ci.yml
|
|
||||||
|
|
||||||
# Data directory (for volume mount)
|
|
||||||
data/
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Docker Environment Configuration
|
|
||||||
# Copy this file to .env and update with your values
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Application Settings
|
|
||||||
# ============================================
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3001
|
|
||||||
LOG_LEVEL=debug
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Security (IMPORTANT: Change in production!)
|
|
||||||
# ============================================
|
|
||||||
# Generate a secure JWT secret with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=change-this-in-production-use-openssl-rand-base64-32
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# CORS Configuration
|
|
||||||
# ============================================
|
|
||||||
# Your application's public URL (e.g., https://awards.example.com)
|
|
||||||
VITE_APP_URL=
|
|
||||||
|
|
||||||
# Comma-separated list of allowed origins for CORS
|
|
||||||
# Only needed if not using same domain deployment
|
|
||||||
# Example: https://awards.example.com,https://www.awards.example.com
|
|
||||||
ALLOWED_ORIGINS=
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Production Configuration Template
|
|
||||||
# Copy this file to .env.production and update with your production values
|
|
||||||
|
|
||||||
# Application Environment
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# Log Level (debug, info, warn, error)
|
|
||||||
# Recommended: info for production
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Server Port (default: 3001)
|
|
||||||
PORT=3001
|
|
||||||
|
|
||||||
# Frontend URL (e.g., https://awards.dj7nt.de)
|
|
||||||
VITE_APP_URL=https://awards.dj7nt.de
|
|
||||||
|
|
||||||
# API Base URL (leave empty for same-domain deployment)
|
|
||||||
VITE_API_BASE_URL=
|
|
||||||
|
|
||||||
# Allowed CORS origins (comma-separated)
|
|
||||||
# Add all domains that should access the API
|
|
||||||
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
|
||||||
|
|
||||||
# JWT Secret (REQUIRED - generate a strong secret!)
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=REPLACE_WITH_SECURE_RANDOM_STRING
|
|
||||||
|
|
||||||
# Database (if using external database)
|
|
||||||
# Leave empty to use default SQLite database
|
|
||||||
# DATABASE_URL=file:/path/to/production.db
|
|
||||||
481
CLAUDE.md
481
CLAUDE.md
@@ -77,58 +77,6 @@ test("hello world", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Deployment
|
|
||||||
|
|
||||||
The application supports Docker deployment with single-port architecture and host-mounted database persistence.
|
|
||||||
|
|
||||||
**Quick Start**:
|
|
||||||
```bash
|
|
||||||
# Create environment file
|
|
||||||
cp .env.docker.example .env
|
|
||||||
|
|
||||||
# Generate JWT secret
|
|
||||||
openssl rand -base64 32 # Add to .env as JWT_SECRET
|
|
||||||
|
|
||||||
# Start application
|
|
||||||
docker-compose up -d --build
|
|
||||||
|
|
||||||
# Access at http://localhost:3001
|
|
||||||
```
|
|
||||||
|
|
||||||
**Architecture**:
|
|
||||||
- **Single Port**: Port 3001 serves both API (`/api/*`) and frontend (all other routes)
|
|
||||||
- **Database Persistence**: SQLite database stored at `./data/award.db` on host
|
|
||||||
- **Auto-initialization**: Database created from template on first startup
|
|
||||||
- **Health Checks**: Built-in health monitoring at `/api/health`
|
|
||||||
|
|
||||||
**Key Docker Files**:
|
|
||||||
- `Dockerfile`: Multi-stage build using official Bun runtime
|
|
||||||
- `docker-compose.yml`: Stack orchestration with volume mounts
|
|
||||||
- `docker-entrypoint.sh`: Database initialization logic
|
|
||||||
- `.env.docker.example`: Environment variable template
|
|
||||||
- `DOCKER.md`: Complete deployment documentation
|
|
||||||
|
|
||||||
**Environment Variables**:
|
|
||||||
- `NODE_ENV`: Environment mode (default: production)
|
|
||||||
- `PORT`: Application port (default: 3001)
|
|
||||||
- `LOG_LEVEL`: Logging level (debug/info/warn/error)
|
|
||||||
- `JWT_SECRET`: JWT signing secret (required, change in production!)
|
|
||||||
- `VITE_APP_URL`: Your application's public URL
|
|
||||||
- `ALLOWED_ORIGINS`: CORS allowed origins (comma-separated)
|
|
||||||
|
|
||||||
**Database Management**:
|
|
||||||
- Database location: `./data/award.db` (host-mounted volume)
|
|
||||||
- Backups: `cp data/award.db data/award.db.backup.$(date +%Y%m%d)`
|
|
||||||
- Reset: `docker-compose down -v && docker-compose up -d`
|
|
||||||
|
|
||||||
**Important Notes**:
|
|
||||||
- Database persists across container restarts/recreations
|
|
||||||
- Frontend dependencies are reinstalled in container to ensure correct platform binaries
|
|
||||||
- Uses custom init script (`src/backend/scripts/init-db.js`) with `bun:sqlite`
|
|
||||||
- Architecture-agnostic (works on x86, ARM64, etc.)
|
|
||||||
|
|
||||||
For detailed documentation, see `DOCKER.md`.
|
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||||
@@ -222,6 +170,8 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
1. **`entity`**: Count unique entities (DXCC countries, states, grid squares)
|
1. **`entity`**: Count unique entities (DXCC countries, states, grid squares)
|
||||||
- `entityType`: What to count ("dxcc", "state", "grid", "callsign")
|
- `entityType`: What to count ("dxcc", "state", "grid", "callsign")
|
||||||
- `target`: Number required for award
|
- `target`: Number required for award
|
||||||
|
- `allowed_bands`: Optional array of bands that count (e.g., `["160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]` for HF only)
|
||||||
|
- `satellite_only`: Optional boolean to only count satellite QSOs (QSOs with `satName` field)
|
||||||
- `filters`: Optional filters (band, mode, etc.)
|
- `filters`: Optional filters (band, mode, etc.)
|
||||||
- `displayField`: Optional field to display
|
- `displayField`: Optional field to display
|
||||||
|
|
||||||
@@ -231,7 +181,6 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
- `filters`: Optional filters (band, mode, etc.) for award variants
|
- `filters`: Optional filters (band, mode, etc.) for award variants
|
||||||
- Counts unique (DOK, band, mode) combinations
|
- Counts unique (DOK, band, mode) combinations
|
||||||
- Only DCL-confirmed QSOs count
|
- Only DCL-confirmed QSOs count
|
||||||
- Example variants: DLD 80m, DLD CW, DLD 80m CW
|
|
||||||
|
|
||||||
3. **`points`**: Point-based awards
|
3. **`points`**: Point-based awards
|
||||||
- `stations`: Array of {callsign, points}
|
- `stations`: Array of {callsign, points}
|
||||||
@@ -244,6 +193,16 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
|
|
||||||
5. **`counter`**: Count QSOs or callsigns
|
5. **`counter`**: Count QSOs or callsigns
|
||||||
|
|
||||||
|
### Current Awards
|
||||||
|
|
||||||
|
- **DXCC**: HF bands only (160m-10m), 100 entities required
|
||||||
|
- **DXCC SAT**: Satellite QSOs only, 100 entities required
|
||||||
|
- **WAS**: Worked All States award
|
||||||
|
- **VUCC SAT**: VUCC Satellite award
|
||||||
|
- **SAT-RS44**: Special satellite award
|
||||||
|
- **73 on 73**: Special stations award
|
||||||
|
- **DLD**: Deutschland Diplom, 100 unique DOKs required
|
||||||
|
|
||||||
### Key Files
|
### Key Files
|
||||||
|
|
||||||
**Backend Award Service**: `src/backend/services/awards.service.js`
|
**Backend Award Service**: `src/backend/services/awards.service.js`
|
||||||
@@ -253,11 +212,13 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
- `calculatePointsAwardProgress(userId, award, options)`: Point-based calculation
|
- `calculatePointsAwardProgress(userId, award, options)`: Point-based calculation
|
||||||
- `getAwardEntityBreakdown(userId, awardId)`: Detailed entity breakdown
|
- `getAwardEntityBreakdown(userId, awardId)`: Detailed entity breakdown
|
||||||
- `getAwardProgressDetails(userId, awardId)`: Progress with details
|
- `getAwardProgressDetails(userId, awardId)`: Progress with details
|
||||||
|
- Implements `allowed_bands` and `satellite_only` filtering
|
||||||
|
|
||||||
**Database Schema**: `src/backend/db/schema/index.js`
|
**Database Schema**: `src/backend/db/schema/index.js`
|
||||||
- QSO fields include: `darcDok`, `dclQslRstatus`, `dclQslRdate`
|
- QSO fields include: `darcDok`, `dclQslRstatus`, `dclQslRdate`, `satName`
|
||||||
- DOK fields support DLD award tracking
|
- DOK fields support DLD award tracking
|
||||||
- DCL confirmation fields separate from LoTW
|
- DCL confirmation fields separate from LoTW
|
||||||
|
- `satName` field for satellite QSO tracking
|
||||||
|
|
||||||
**Award Definitions**: `award-definitions/*.json`
|
**Award Definitions**: `award-definitions/*.json`
|
||||||
- Add new awards by creating JSON definition files
|
- Add new awards by creating JSON definition files
|
||||||
@@ -268,7 +229,6 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
- Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`)
|
- Handles case-insensitive `<EOR>` delimiters (supports `<EOR>`, `<eor>`, `<Eor>`)
|
||||||
- Uses `matchAll()` for reliable field parsing
|
- Uses `matchAll()` for reliable field parsing
|
||||||
- Skips header records automatically
|
- Skips header records automatically
|
||||||
- `parseDCLResponse(response)`: Parse DCL's JSON response format `{ "adif": "..." }`
|
|
||||||
- `normalizeBand(band)`: Standardize band names (80m, 40m, etc.)
|
- `normalizeBand(band)`: Standardize band names (80m, 40m, etc.)
|
||||||
- `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.)
|
- `normalizeMode(mode)`: Standardize mode names (CW, FT8, SSB, etc.)
|
||||||
- Used by both LoTW and DCL services for consistency
|
- Used by both LoTW and DCL services for consistency
|
||||||
@@ -289,6 +249,7 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
- `POST /api/dcl/sync`: Queue DCL sync job
|
- `POST /api/dcl/sync`: Queue DCL sync job
|
||||||
- `GET /api/jobs/:jobId`: Get job status
|
- `GET /api/jobs/:jobId`: Get job status
|
||||||
- `GET /api/jobs/active`: Get active job for current user
|
- `GET /api/jobs/active`: Get active job for current user
|
||||||
|
- `DELETE /api/qsos/all`: Delete all QSOs for authenticated user
|
||||||
- `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback
|
- `GET /*`: Serves static files from `src/frontend/build/` with SPA fallback
|
||||||
|
|
||||||
**SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`.
|
**SPA Routing**: The backend serves the SvelteKit frontend build from `src/frontend/build/`.
|
||||||
@@ -314,9 +275,9 @@ The award system is JSON-driven and located in `award-definitions/` directory. E
|
|||||||
- Fully implemented and functional
|
- Fully implemented and functional
|
||||||
- **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details
|
- **Note**: DCL API is a custom prototype by DARC; contact DARC for API specification details
|
||||||
|
|
||||||
### DLD Award Implementation (COMPLETED)
|
### DLD Award Implementation
|
||||||
|
|
||||||
The DLD (Deutschland Diplom) award was recently implemented:
|
The DLD (Deutschland Diplom) award:
|
||||||
|
|
||||||
**Definition**: `award-definitions/dld.json`
|
**Definition**: `award-definitions/dld.json`
|
||||||
```json
|
```json
|
||||||
@@ -336,7 +297,7 @@ The DLD (Deutschland Diplom) award was recently implemented:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Implementation Details**:
|
**Implementation Details**:
|
||||||
- Function: `calculateDOKAwardProgress()` in `src/backend/services/awards.service.js` (lines 173-268)
|
- Function: `calculateDOKAwardProgress()` in `src/backend/services/awards.service.js`
|
||||||
- Counts unique (DOK, band, mode) combinations
|
- Counts unique (DOK, band, mode) combinations
|
||||||
- Only DCL-confirmed QSOs count (`dclQslRstatus === 'Y'`)
|
- Only DCL-confirmed QSOs count (`dclQslRstatus === 'Y'`)
|
||||||
- Each unique DOK on each unique band/mode counts separately
|
- Each unique DOK on each unique band/mode counts separately
|
||||||
@@ -349,8 +310,6 @@ The DLD (Deutschland Diplom) award was recently implemented:
|
|||||||
- `dclQslRstatus`: DCL confirmation status ('Y' = confirmed)
|
- `dclQslRstatus`: DCL confirmation status ('Y' = confirmed)
|
||||||
- `dclQslRdate`: DCL confirmation date
|
- `dclQslRdate`: DCL confirmation date
|
||||||
|
|
||||||
**Documentation**: See `docs/DOCUMENTATION.md` for complete documentation including DLD award example.
|
|
||||||
|
|
||||||
**Frontend**: `src/frontend/src/routes/qsos/+page.svelte`
|
**Frontend**: `src/frontend/src/routes/qsos/+page.svelte`
|
||||||
- Separate sync buttons for LoTW (blue) and DCL (orange)
|
- Separate sync buttons for LoTW (blue) and DCL (orange)
|
||||||
- Independent progress tracking for each sync type
|
- Independent progress tracking for each sync type
|
||||||
@@ -374,79 +333,59 @@ To add a new award:
|
|||||||
3. If new rule type needed, add calculation function
|
3. If new rule type needed, add calculation function
|
||||||
4. Add type handling in `calculateAwardProgress()` switch statement
|
4. Add type handling in `calculateAwardProgress()` switch statement
|
||||||
5. Add type handling in `getAwardEntityBreakdown()` if needed
|
5. Add type handling in `getAwardEntityBreakdown()` if needed
|
||||||
6. Update documentation in `docs/DOCUMENTATION.md`
|
6. Update documentation
|
||||||
7. Test with sample QSO data
|
7. Test with sample QSO data
|
||||||
|
|
||||||
### Creating DLD Award Variants
|
### Award Rule Options
|
||||||
|
|
||||||
The DOK award type supports filters to create award variants. Examples:
|
**allowed_bands**: Restrict which bands count toward an award
|
||||||
|
|
||||||
**DLD on 80m** (`dld-80m.json`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "dld-80m",
|
|
||||||
"name": "DLD 80m",
|
|
||||||
"description": "Confirm 100 unique DOKs on 80m",
|
|
||||||
"caption": "Contact 100 different DOKs on the 80m band.",
|
|
||||||
"category": "darc",
|
|
||||||
"rules": {
|
|
||||||
"type": "dok",
|
|
||||||
"target": 100,
|
|
||||||
"confirmationType": "dcl",
|
|
||||||
"displayField": "darcDok",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "band", "operator": "eq", "value": "80m" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**DLD in CW mode** (`dld-cw.json`):
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "dok",
|
"type": "entity",
|
||||||
"target": 100,
|
"allowed_bands": ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m"]
|
||||||
"confirmationType": "dcl",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "mode", "operator": "eq", "value": "CW" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- If absent or empty, all bands are allowed (default behavior)
|
||||||
|
- Used for DXCC to restrict to HF bands only
|
||||||
|
|
||||||
**DLD on 80m using CW** (combined filters, `dld-80m-cw.json`):
|
**satellite_only**: Only count satellite QSOs
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "dok",
|
"type": "entity",
|
||||||
"target": 100,
|
"satellite_only": true
|
||||||
"confirmationType": "dcl",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "band", "operator": "eq", "value": "80m" },
|
|
||||||
{ "field": "mode", "operator": "eq", "value": "CW" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- If `true`, only QSOs with `satName` field set are counted
|
||||||
|
- Used for DXCC SAT award
|
||||||
|
|
||||||
**Available filter operators**:
|
**modeGroups**: Define mode groups for filtering in award detail view
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modeGroups": {
|
||||||
|
"Digi-Modes": ["FT8", "FT4", "MFSK", "PSK31", "RTTY", "JT65", "JT9"],
|
||||||
|
"Classic Digi-Modes": ["PSK31", "RTTY", "JT65", "JT9"],
|
||||||
|
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
|
||||||
|
"Phone-Modes": ["AM", "SSB", "FM"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Optional field at award definition level (not in `rules`)
|
||||||
|
- Key is the display name shown in the mode filter dropdown
|
||||||
|
- Value is an array of mode strings to include in the group
|
||||||
|
- Used to create convenient mode filters that combine multiple modes
|
||||||
|
- Awards without `modeGroups` work as before (backward compatible)
|
||||||
|
|
||||||
|
**filters**: Additional filtering options
|
||||||
- `eq`: equals
|
- `eq`: equals
|
||||||
- `ne`: not equals
|
- `ne`: not equals
|
||||||
- `in`: in array
|
- `in`: in array
|
||||||
- `nin`: not in array
|
- `nin`: not in array
|
||||||
- `contains`: contains substring
|
- `contains`: contains substring
|
||||||
|
- Can filter any QSO field (band, mode, callsign, grid, state, etc.)
|
||||||
**Available filter fields**: Any QSO field (band, mode, callsign, grid, state, satName, etc.)
|
|
||||||
|
|
||||||
### Confirmation Systems
|
### Confirmation Systems
|
||||||
|
|
||||||
@@ -466,13 +405,8 @@ The DOK award type supports filters to create award variants. Examples:
|
|||||||
- Required for DLD award
|
- Required for DLD award
|
||||||
- German amateur radio specific
|
- German amateur radio specific
|
||||||
- Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }`
|
- Request format: POST JSON `{ key, limit, qsl_since, qso_since, cnf_only }`
|
||||||
- `cnf_only: null` - Fetch all QSOs (confirmed + unconfirmed)
|
|
||||||
- `cnf_only: true` - Fetch only confirmed QSOs
|
|
||||||
- `qso_since` - QSOs since this date (YYYYMMDD)
|
|
||||||
- `qsl_since` - QSL confirmations since this date (YYYYMMDD)
|
|
||||||
- Response format: JSON with ADIF string in `adif` field
|
- Response format: JSON with ADIF string in `adif` field
|
||||||
- Syncs ALL QSOs (both confirmed and unconfirmed)
|
- Syncs ALL QSOs (both confirmed and unconfirmed)
|
||||||
- Unconfirmed QSOs stored but don't count toward awards
|
|
||||||
- Updates QSOs only if confirmation data has changed
|
- Updates QSOs only if confirmation data has changed
|
||||||
|
|
||||||
### ADIF Format
|
### ADIF Format
|
||||||
@@ -491,138 +425,13 @@ Both LoTW and DCL return data in ADIF (Amateur Data Interchange Format):
|
|||||||
- `MY_DARC_DOK`: User's own DOK
|
- `MY_DARC_DOK`: User's own DOK
|
||||||
- `STATION_CALLSIGN`: User's callsign
|
- `STATION_CALLSIGN`: User's callsign
|
||||||
|
|
||||||
### Recent Commits
|
### QSO Management
|
||||||
|
|
||||||
- `aeeb75c`: feat: add QSO count display to filter section
|
**Delete All QSOs**: `DELETE /api/qsos/all`
|
||||||
- Shows count of QSOs matching current filters next to "Filters" heading
|
- Deletes all QSOs for authenticated user
|
||||||
- Displays "Showing X filtered QSOs" when filters are active
|
- Also deletes related `qso_changes` records to satisfy foreign key constraints
|
||||||
- Displays "Showing X total QSOs" when no filters applied
|
- Invalidates stats and user caches after deletion
|
||||||
- Dynamically updates when filters change
|
- Returns count of deleted QSOs
|
||||||
- `bee02d1`: fix: count QSOs confirmed by either LoTW or DCL in stats
|
|
||||||
- QSO stats were only counting LoTW-confirmed QSOs (`lotwQslRstatus === 'Y'`)
|
|
||||||
- QSOs confirmed only by DCL were excluded from "confirmed" count
|
|
||||||
- Fixed by changing filter to: `q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y'`
|
|
||||||
- Now correctly shows all QSOs confirmed by at least one system
|
|
||||||
- `233888c`: fix: make ADIF parser case-insensitive for EOR delimiter
|
|
||||||
- **Critical bug**: LoTW uses lowercase `<eor>` tags, parser was splitting on uppercase `<EOR>`
|
|
||||||
- Caused 242K+ QSOs to be parsed as 1 giant record with fields overwriting each other
|
|
||||||
- Changed to case-insensitive regex: `new RegExp('<eor>', 'gi')`
|
|
||||||
- Replaced `regex.exec()` while loop with `matchAll()` for-of iteration
|
|
||||||
- Now correctly imports all QSOs from large LoTW reports
|
|
||||||
- `645f786`: fix: add missing timeOn field to LoTW duplicate detection
|
|
||||||
- LoTW sync was missing `timeOn` in duplicate detection query
|
|
||||||
- Multiple QSOs with same callsign/date/band/mode but different times were treated as duplicates
|
|
||||||
- Now matches DCL sync logic: `userId, callsign, qsoDate, timeOn, band, mode`
|
|
||||||
- `7f77c3a`: feat: add filter support for DOK awards
|
|
||||||
- DOK award type now supports filtering by band, mode, and other QSO fields
|
|
||||||
- Allows creating award variants like DLD 80m, DLD CW, DLD 80m CW
|
|
||||||
- Uses existing filter system with eq, ne, in, nin, contains operators
|
|
||||||
- Example awards created: dld-80m, dld-40m, dld-cw, dld-80m-cw
|
|
||||||
- `9e73704`: docs: update CLAUDE.md with DLD award variants documentation
|
|
||||||
- `7201446`: fix: return proper HTML for SPA routes instead of Bun error page
|
|
||||||
- When accessing client-side routes (like /qsos) via curl or non-JS clients,
|
|
||||||
the server attempted to open them as static files, causing Bun to throw
|
|
||||||
an unhandled ENOENT error that showed an ugly error page
|
|
||||||
- Now checks if a path has a file extension before attempting to serve it
|
|
||||||
- Paths without extensions are immediately served index.html for SPA routing
|
|
||||||
- Also improves the 503 error page with user-friendly HTML when frontend build is missing
|
|
||||||
- `223461f`: fix: enable debug logging and improve DCL sync observability
|
|
||||||
- `27d2ef1`: fix: preserve DOK data when DCL doesn't send values
|
|
||||||
- DCL sync only updates DOK/grid fields when DCL provides non-empty values
|
|
||||||
- Prevents accidentally clearing DOK data from manual entry or other sources
|
|
||||||
- Preserves existing DOK when DCL syncs QSO without DOK information
|
|
||||||
- `e09ab94`: feat: skip QSOs with unchanged confirmation data
|
|
||||||
- LoTW/DCL sync only updates QSOs if confirmation data has changed
|
|
||||||
- Tracks added, updated, and skipped QSO counts
|
|
||||||
- LoTW: Checks if lotwQslRstatus or lotwQslRdate changed
|
|
||||||
- DCL: Checks if dclQslRstatus, dclQslRdate, darcDok, myDarcDok, or grid changed
|
|
||||||
- `3592dbb`: feat: add import log showing synced QSOs
|
|
||||||
- Backend returns addedQSOs and updatedQSOs arrays in sync result
|
|
||||||
- Frontend displays import log with callsign, date, band, mode for each QSO
|
|
||||||
- Separate sections for "New QSOs" and "Updated QSOs"
|
|
||||||
- Sync summary shows total, added, updated, skipped counts
|
|
||||||
- `8a1a580`: feat: implement DCL ADIF parser and service integration
|
|
||||||
- Add shared ADIF parser utility (src/backend/utils/adif-parser.js)
|
|
||||||
- Implement DCL service with API integration
|
|
||||||
- Refactor LoTW service to use shared parser
|
|
||||||
- Tested with example DCL payload (6 QSOs parsed successfully)
|
|
||||||
- `c982dcd`: feat: implement DLD (Deutschland Diplom) award
|
|
||||||
- `322ccaf`: docs: add DLD (Deutschland Diplom) award documentation
|
|
||||||
|
|
||||||
### Sync Behavior
|
|
||||||
|
|
||||||
**Import Log**: After each sync, displays a table showing:
|
|
||||||
- New QSOs: Callsign, Date, Band, Mode
|
|
||||||
- Updated QSOs: Callsign, Date, Band, Mode (only if data changed)
|
|
||||||
- Skipped QSOs: Counted but not shown (data unchanged)
|
|
||||||
|
|
||||||
**Duplicate Handling**:
|
|
||||||
- QSOs matched by: userId, callsign, qsoDate, timeOn, band, mode
|
|
||||||
- If confirmation data unchanged: Skipped (not updated)
|
|
||||||
- If confirmation data changed: Updated with new values
|
|
||||||
- Prevents unnecessary database writes and shows accurate import counts
|
|
||||||
|
|
||||||
**DOK Update Behavior**:
|
|
||||||
- If QSO imported via LoTW (no DOK) and later DCL confirms with DOK: DOK is added ✓
|
|
||||||
- If QSO already has DOK and DCL sends different DOK: DOK is updated ✓
|
|
||||||
- If QSO has DOK and DCL syncs without DOK (empty): Existing DOK is preserved ✓
|
|
||||||
- LoTW never sends DOK data; only DCL provides DOK fields
|
|
||||||
|
|
||||||
**Important**: DCL sync only updates DOK/grid fields when DCL provides non-empty values. This prevents accidentally clearing DOK data that was manually entered or imported from other sources.
|
|
||||||
|
|
||||||
### DCL Sync Strategy
|
|
||||||
|
|
||||||
**Current Behavior**: DCL syncs ALL QSOs (confirmed + unconfirmed)
|
|
||||||
|
|
||||||
The application syncs both confirmed and unconfirmed QSOs from DCL:
|
|
||||||
- **Confirmed QSOs**: `dclQslRstatus = 'Y'` - Count toward awards
|
|
||||||
- **Unconfirmed QSOs**: `dclQslRstatus = 'N'` - Stored but don't count toward awards
|
|
||||||
|
|
||||||
**Purpose of syncing unconfirmed QSOs**:
|
|
||||||
- Users can see who they've worked (via "Not Confirmed" filter)
|
|
||||||
- Track QSOs awaiting confirmation
|
|
||||||
- QSOs can get confirmed later and will be updated on next sync
|
|
||||||
|
|
||||||
**Award Calculation**: Always uses confirmed QSOs only (e.g., `dclQslRstatus === 'Y'` for DLD award)
|
|
||||||
|
|
||||||
### DCL Incremental Sync Strategy
|
|
||||||
|
|
||||||
**Challenge**: Need to fetch both new QSOs AND confirmation updates to old QSOs
|
|
||||||
|
|
||||||
**Example Scenario**:
|
|
||||||
1. Full sync on 2026-01-20 → Last QSO date: 2026-01-20
|
|
||||||
2. User works 3 new QSOs on 2026-01-25 (unconfirmed)
|
|
||||||
3. Old QSO from 2026-01-10 gets confirmed on 2026-01-26
|
|
||||||
4. Next sync needs both: new QSOs (2026-01-25) AND confirmation update (2026-01-10)
|
|
||||||
|
|
||||||
**Solution**: Use both `qso_since` and `qsl_since` parameters with OR logic
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Proposed sync logic (requires OR logic from DCL API)
|
|
||||||
const lastQSODate = await getLastDCLQSODate(userId); // Track QSO dates
|
|
||||||
const lastQSLDate = await getLastDCLQSLDate(userId); // Track QSL dates
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
key: dclApiKey,
|
|
||||||
limit: 50000,
|
|
||||||
qso_since: lastQSODate, // Get new QSOs since last contact
|
|
||||||
qsl_since: lastQSLDate, // Get QSL confirmations since last sync
|
|
||||||
cnf_only: null, // Fetch all QSOs
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Required API Behavior (OR Logic)**:
|
|
||||||
- Return QSOs where `(qso_date >= qso_since) OR (qsl_date >= qsl_since)`
|
|
||||||
- This ensures we get both new QSOs and confirmation updates
|
|
||||||
|
|
||||||
**Current DCL API Status**:
|
|
||||||
- Unknown if current API uses AND or OR logic for combined filters
|
|
||||||
- **Action Needed**: Request OR logic implementation from DARC
|
|
||||||
- Test current behavior to confirm API response pattern
|
|
||||||
|
|
||||||
**Why OR Logic is Needed**:
|
|
||||||
- With AND logic: Old QSOs getting confirmed are missed (qso_date too old)
|
|
||||||
- With OR logic: All updates captured efficiently in one API call
|
|
||||||
|
|
||||||
### QSO Page Filters
|
### QSO Page Filters
|
||||||
|
|
||||||
@@ -630,34 +439,50 @@ The QSO page (`src/frontend/src/routes/qsos/+page.svelte`) includes advanced fil
|
|||||||
|
|
||||||
**Available Filters**:
|
**Available Filters**:
|
||||||
- **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields
|
- **Search Box**: Full-text search across callsign, entity (DXCC country), and grid square fields
|
||||||
- Press Enter to apply search
|
|
||||||
- Case-insensitive partial matching
|
|
||||||
- **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
|
- **Band Filter**: Dropdown to filter by amateur band (160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm)
|
||||||
- **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9)
|
- **Mode Filter**: Dropdown to filter by mode (CW, SSB, AM, FM, RTTY, PSK31, FT8, FT4, JT65, JT9)
|
||||||
- **Confirmation Type Filter**: Filter by confirmation status
|
- **Confirmation Type Filter**: Filter by confirmation status
|
||||||
- "All QSOs": Shows all QSOs (no filter)
|
- "All QSOs", "LoTW Only", "DCL Only", "Both Confirmed", "Not Confirmed"
|
||||||
- "LoTW Only": Shows QSOs confirmed by LoTW but NOT DCL
|
- **Clear Button**: Resets all filters
|
||||||
- "DCL Only": Shows QSOs confirmed by DCL but NOT LoTW
|
|
||||||
- "Both Confirmed": Shows QSOs confirmed by BOTH LoTW AND DCL
|
|
||||||
- "Not Confirmed": Shows QSOs confirmed by NEITHER LoTW nor DCL
|
|
||||||
- **Clear Button**: Resets all filters and reloads all QSOs
|
|
||||||
|
|
||||||
**Backend Implementation** (`src/backend/services/lotw.service.js`):
|
**Backend Implementation** (`src/backend/services/lotw.service.js`):
|
||||||
- `getUserQSOs(userId, filters, options)`: Main filtering function
|
- `getUserQSOs(userId, filters, options)`: Main filtering function
|
||||||
- Supports pagination with `page` and `limit` options
|
- Supports pagination with `page` and `limit` options
|
||||||
- Filter logic uses Drizzle ORM query builders for safe SQL generation
|
- Filter logic uses Drizzle ORM query builders for safe SQL generation
|
||||||
- Debug logging when `LOG_LEVEL=debug` shows applied filters
|
|
||||||
|
|
||||||
**Frontend API** (`src/frontend/src/lib/api.js`):
|
**Frontend API** (`src/frontend/src/lib/api.js`):
|
||||||
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
|
- `qsosAPI.getAll(filters)`: Fetch QSOs with optional filters
|
||||||
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
|
- Filters passed as query parameters: `?band=20m&mode=CW&confirmationType=lotw&search=DL`
|
||||||
|
|
||||||
**QSO Count Display**:
|
### Award Detail View
|
||||||
- Shows count of QSOs matching current filters next to "Filters" heading
|
|
||||||
- **With filters active**: "Showing **X** filtered QSOs"
|
**Overview**: The award detail page (`src/frontend/src/routes/awards/[id]/+page.svelte`) displays award progress in a pivot table format.
|
||||||
- **No filters**: "Showing **X** total QSOs"
|
|
||||||
- Dynamically updates when filters are applied or cleared
|
**Key Features**:
|
||||||
- Uses `pagination.totalCount` from backend API response
|
- **Summary Cards**: Show total, confirmed, worked, needed counts for unique entities
|
||||||
|
- **Mode Filter**: Filter by specific mode, mode group, or view "Mixed Mode" (aggregates all modes by band)
|
||||||
|
- Awards can define `modeGroups` to create convenient multi-mode filters
|
||||||
|
- Example groups: "Digi-Modes", "Classic Digi-Modes", "Phone-Modes", "Mixed-Mode w/o WSJT-Modes"
|
||||||
|
- Visual separator (`─────`) appears between mode groups and individual modes
|
||||||
|
- **Table Columns**: Show bands (or band/mode combinations) as columns
|
||||||
|
- **QSO Counts**: Each cell shows count of confirmed QSOs for that (entity, band, mode) slot
|
||||||
|
- **Drill-Down**: Click a count to open modal showing all QSOs for that slot
|
||||||
|
- **QSO Detail**: Click any QSO to view full QSO details
|
||||||
|
- **Satellite Grouping**: Satellite QSOs grouped under "SAT" column instead of frequency band
|
||||||
|
|
||||||
|
**Column Sorting**: Bands sorted by wavelength (longest to shortest):
|
||||||
|
160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm, SAT
|
||||||
|
|
||||||
|
**Column Sums**: Show unique entity count per column (not QSO counts)
|
||||||
|
|
||||||
|
**Backend Changes** (`src/backend/services/awards.service.js`):
|
||||||
|
- `getAllAwards()`: Returns award definitions including `modeGroups`
|
||||||
|
- `getAwardById(awardId)`: Returns single award definition with `modeGroups`
|
||||||
|
- `calculateDOKAwardProgress()`: Groups by (DOK, band, mode) slots, collects QSOs in `qsos` array
|
||||||
|
- `calculatePointsAwardProgress()`: Handles all count modes with `qsos` array
|
||||||
|
- `getAwardEntityBreakdown()`: Groups by (entity, band, mode) slots
|
||||||
|
- Includes `satName` in QSO data for satellite grouping
|
||||||
|
- Implements `allowed_bands` and `satellite_only` filtering
|
||||||
|
|
||||||
### DXCC Entity Priority Logic
|
### DXCC Entity Priority Logic
|
||||||
|
|
||||||
@@ -665,58 +490,18 @@ When syncing QSOs from multiple confirmation sources, the system follows a prior
|
|||||||
|
|
||||||
**Priority Order**: LoTW > DCL
|
**Priority Order**: LoTW > DCL
|
||||||
|
|
||||||
**Implementation** (`src/backend/services/dcl.service.js`):
|
|
||||||
```javascript
|
|
||||||
// DXCC priority: LoTW > DCL
|
|
||||||
// Only update entity fields from DCL if:
|
|
||||||
// 1. QSO is NOT LoTW confirmed, AND
|
|
||||||
// 2. DCL actually sent entity data, AND
|
|
||||||
// 3. Current entity is missing
|
|
||||||
const hasLoTWConfirmation = existingQSO.lotwQslRstatus === 'Y';
|
|
||||||
const hasDCLData = dbQSO.entity || dbQSO.entityId;
|
|
||||||
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
|
|
||||||
|
|
||||||
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
|
||||||
// Fill in entity data from DCL (only if DCL provides it)
|
|
||||||
updateData.entity = dbQSO.entity;
|
|
||||||
updateData.entityId = dbQSO.entityId;
|
|
||||||
// ... other entity fields
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules**:
|
**Rules**:
|
||||||
1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable)
|
1. **LoTW-confirmed QSOs**: Always use LoTW's DXCC data (most reliable)
|
||||||
2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload
|
2. **DCL-only QSOs**: Use DCL's DXCC data IF available in ADIF payload
|
||||||
3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty
|
3. **Empty entity fields**: If DCL doesn't send DXCC data, entity remains empty
|
||||||
4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it
|
4. **Never overwrite**: Once LoTW confirms with entity data, DCL sync won't change it
|
||||||
|
|
||||||
**Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export. This is a limitation of the DCL API, not the application. If DCL adds these fields in the future, the system will automatically use them for DCL-only QSOs.
|
**Important Note**: DCL API currently doesn't send DXCC/entity fields in their ADIF export.
|
||||||
|
|
||||||
### Recent Development Work (January 2025)
|
### Critical LoTW Sync Behavior
|
||||||
|
|
||||||
**QSO Page Enhancements**:
|
|
||||||
- Added confirmation type filter with exclusive logic (LoTW Only, DCL Only, Both Confirmed, Not Confirmed)
|
|
||||||
- Added search box for filtering by callsign, entity, or grid square
|
|
||||||
- Renamed "All Confirmation" to "All QSOs" for clarity
|
|
||||||
- Fixed filter logic to properly handle exclusive confirmation types
|
|
||||||
|
|
||||||
**Bug Fixes**:
|
|
||||||
- Fixed confirmation filter showing wrong QSOs (e.g., "LoTW Only" was also showing DCL QSOs)
|
|
||||||
- Implemented proper SQL conditions for exclusive filters using separate condition pushes
|
|
||||||
- Added debug logging to track filter application
|
|
||||||
|
|
||||||
**DXCC Entity Handling**:
|
|
||||||
- Clarified that DCL API doesn't send DXCC fields (current limitation)
|
|
||||||
- Implemented priority logic: LoTW entity data takes precedence over DCL
|
|
||||||
- System ready to auto-use DCL DXCC data if they add it in future API updates
|
|
||||||
|
|
||||||
### Critical LoTW Sync Behavior (LEARNED THE HARD WAY)
|
|
||||||
|
|
||||||
**⚠️ IMPORTANT: LoTW sync MUST only import confirmed QSOs**
|
**⚠️ IMPORTANT: LoTW sync MUST only import confirmed QSOs**
|
||||||
|
|
||||||
After attempting to implement "QSO Delta" sync (all QSOs, confirmed + unconfirmed), we discovered:
|
|
||||||
|
|
||||||
**The Problem:**
|
|
||||||
LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
|
LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
|
||||||
- `CALL` (callsign)
|
- `CALL` (callsign)
|
||||||
- `QSL_RCVD` (confirmation status: Y/N)
|
- `QSL_RCVD` (confirmation status: Y/N)
|
||||||
@@ -724,9 +509,7 @@ LoTW ADIF export with `qso_qsl=no` (all QSOs mode) only includes:
|
|||||||
**Missing Fields for Unconfirmed QSOs:**
|
**Missing Fields for Unconfirmed QSOs:**
|
||||||
- `DXCC` (entity ID) ← **CRITICAL for awards!**
|
- `DXCC` (entity ID) ← **CRITICAL for awards!**
|
||||||
- `COUNTRY` (entity name)
|
- `COUNTRY` (entity name)
|
||||||
- `CONTINENT`
|
- `CONTINENT`, `CQ_ZONE`, `ITU_ZONE`
|
||||||
- `CQ_ZONE`
|
|
||||||
- `ITU_ZONE`
|
|
||||||
|
|
||||||
**Result:** Unconfirmed QSOs have `entityId: null` and `entity: ""`, breaking award calculations.
|
**Result:** Unconfirmed QSOs have `entityId: null` and `entity: ""`, breaking award calculations.
|
||||||
|
|
||||||
@@ -742,67 +525,31 @@ const params = new URLSearchParams({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why This Matters:**
|
### Recent Development Work (January 2026)
|
||||||
- Awards require `entityId` to count entities
|
|
||||||
- Without `entityId`, QSOs can't be counted toward DXCC, WAS, etc.
|
|
||||||
- Users can still see "worked" stations in QSO list, but awards only count confirmed
|
|
||||||
- DCL sync can import all QSOs because it provides entity data via callsign lookup
|
|
||||||
|
|
||||||
**Attempted Solution (REVERTED):**
|
**Award System Enhancements**:
|
||||||
- Tried implementing callsign prefix lookup to populate missing `entityId`
|
- Added `allowed_bands` filter to restrict which bands count toward awards
|
||||||
- Created `src/backend/utils/callsign-lookup.js` with basic prefix mappings
|
- Added `satellite_only` flag for satellite-only awards
|
||||||
- Complexity: 1000+ DXCC entities, many special event callsigns, portable designators
|
- DXCC restricted to HF bands (160m-10m) only
|
||||||
- Decision: Too complex, reverted (commit 310b154)
|
- 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
|
||||||
|
|
||||||
**Takeaway:** LoTW confirmed QSOs have reliable DXCC data. Don't try to workaround this fundamental limitation.
|
**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)
|
||||||
|
|
||||||
### QSO Confirmation Filters
|
**Backend API Additions**:
|
||||||
|
- Added `GET /api/awards/:awardId` endpoint for fetching single award definition
|
||||||
|
- `getAllAwards()` now includes `modeGroups` field
|
||||||
|
|
||||||
Added "Confirmed by at least 1 service" filter to QSO view (commit 688b0fc):
|
**QSO Management**:
|
||||||
|
- Fixed DELETE /api/qsos/all to handle foreign key constraints
|
||||||
**Filter Options:**
|
- Added cache invalidation after QSO deletion
|
||||||
- "All QSOs" - No filter
|
|
||||||
- "Confirmed by at least 1 service" (NEW) - LoTW OR DCL confirmed
|
|
||||||
- "LoTW Only" - Confirmed by LoTW but NOT DCL
|
|
||||||
- "DCL Only" - Confirmed by DCL but NOT LoTW
|
|
||||||
- "Both Confirmed" - Confirmed by BOTH LoTW AND DCL
|
|
||||||
- "Not Confirmed" - Confirmed by NEITHER
|
|
||||||
|
|
||||||
**SQL Logic:**
|
|
||||||
```sql
|
|
||||||
-- "Confirmed by at least 1 service"
|
|
||||||
WHERE lotwQslRstatus = 'Y' OR dclQslRstatus = 'Y'
|
|
||||||
|
|
||||||
-- "LoTW Only"
|
|
||||||
WHERE lotwQslRstatus = 'Y' AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
|
|
||||||
|
|
||||||
-- "DCL Only"
|
|
||||||
WHERE dclQslRstatus = 'Y' AND (lotwQslRstatus IS NULL OR lotwQslRstatus != 'Y')
|
|
||||||
|
|
||||||
-- "Both Confirmed"
|
|
||||||
WHERE lotwQslRstatus = 'Y' AND dclQslRstatus = 'Y'
|
|
||||||
|
|
||||||
-- "Not Confirmed"
|
|
||||||
WHERE (lotwQslRstatus IS NULL OR lotwQslRstatus != 'Y')
|
|
||||||
AND (dclQslRstatus IS NULL OR dclQslRstatus != 'Y')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recent Development Work (January 2025)
|
|
||||||
|
|
||||||
**Sync Type Support (ATTEMPTED & REVERTED):**
|
|
||||||
- Commit 5b78935: Added LoTW sync type support (QSL/QSO delta/full)
|
|
||||||
- Commit 310b154: Reverted - LoTW doesn't provide entity data for unconfirmed QSOs
|
|
||||||
- **Lesson:** Keep it simple - only sync confirmed QSOs from LoTW
|
|
||||||
|
|
||||||
**Dashboard Enhancements:**
|
|
||||||
- Added sync job history display with real-time polling (every 2 seconds)
|
|
||||||
- Shows job progress, status, and import logs
|
|
||||||
- Cancel button for stale/failed jobs with rollback capability
|
|
||||||
- Tracks all QSO changes in `qso_changes` table for rollback
|
|
||||||
|
|
||||||
**Rollback System:**
|
|
||||||
- `cancelJob(jobId, userId)` - Cancels and rolls back sync jobs
|
|
||||||
- Tracks added QSOs (deletes them on rollback)
|
|
||||||
- Tracks updated QSOs (restores previous state)
|
|
||||||
- Only allows canceling failed jobs or stale running jobs (>1 hour)
|
|
||||||
- Server-side validation prevents unauthorized cancellations
|
|
||||||
|
|||||||
219
DOCKER.md
219
DOCKER.md
@@ -1,219 +0,0 @@
|
|||||||
# Docker Deployment Guide
|
|
||||||
|
|
||||||
This guide covers deploying Quickawards using Docker.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Create environment file:**
|
|
||||||
```bash
|
|
||||||
cp .env.docker.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Generate secure JWT secret:**
|
|
||||||
```bash
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
Copy the output and set it as `JWT_SECRET` in `.env`.
|
|
||||||
|
|
||||||
3. **Update `.env` with your settings:**
|
|
||||||
- `JWT_SECRET`: Strong random string (required)
|
|
||||||
- `VITE_APP_URL`: Your domain (e.g., `https://awards.example.com`)
|
|
||||||
- `ALLOWED_ORIGINS`: Your domain(s) for CORS
|
|
||||||
|
|
||||||
4. **Start the application:**
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Access the application:**
|
|
||||||
- URL: http://localhost:3001
|
|
||||||
- Health check: http://localhost:3001/api/health
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Single Port Design
|
|
||||||
|
|
||||||
The Docker stack exposes a single port (3001) which serves both:
|
|
||||||
- **Backend API** (`/api/*`)
|
|
||||||
- **Frontend SPA** (all other routes)
|
|
||||||
|
|
||||||
### Database Persistence
|
|
||||||
|
|
||||||
- **Location**: `./data/award.db` (host-mounted volume)
|
|
||||||
- **Initialization**: Automatic on first startup
|
|
||||||
- **Persistence**: Database survives container restarts/recreations
|
|
||||||
|
|
||||||
### Startup Behavior
|
|
||||||
|
|
||||||
1. **First startup**: Database is created from template
|
|
||||||
2. **Subsequent startups**: Existing database is used
|
|
||||||
3. **Container recreation**: Database persists in volume
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### Start the application
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### View logs
|
|
||||||
```bash
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stop the application
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rebuild after code changes
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stop and remove everything (including database volume)
|
|
||||||
```bash
|
|
||||||
docker-compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
|
||||||
|----------|----------|---------|-------------|
|
|
||||||
| `NODE_ENV` | No | `production` | Environment mode |
|
|
||||||
| `PORT` | No | `3001` | Application port |
|
|
||||||
| `LOG_LEVEL` | No | `info` | Logging level (debug/info/warn/error) |
|
|
||||||
| `JWT_SECRET` | **Yes** | - | JWT signing secret (change this!) |
|
|
||||||
| `VITE_APP_URL` | No | - | Your application's public URL |
|
|
||||||
| `ALLOWED_ORIGINS` | No | - | CORS allowed origins (comma-separated) |
|
|
||||||
|
|
||||||
## Database Management
|
|
||||||
|
|
||||||
### Backup the database
|
|
||||||
```bash
|
|
||||||
cp data/award.db data/award.db.backup.$(date +%Y%m%d)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Restore from backup
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
cp data/award.db.backup.YYYYMMDD data/award.db
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reset the database
|
|
||||||
```bash
|
|
||||||
docker-compose down -v
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container won't start
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Check container status
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database errors
|
|
||||||
```bash
|
|
||||||
# Check database file exists
|
|
||||||
ls -la data/
|
|
||||||
|
|
||||||
# Check database permissions
|
|
||||||
stat data/award.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port already in use
|
|
||||||
Change the port mapping in `docker-compose.yml`:
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- "8080:3001" # Maps host port 8080 to container port 3001
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health check failing
|
|
||||||
```bash
|
|
||||||
# Check if container is responding
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
|
|
||||||
# Check container logs
|
|
||||||
docker-compose logs quickawards
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### Using a Reverse Proxy (nginx)
|
|
||||||
|
|
||||||
Example nginx configuration:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name awards.example.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3001;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL/TLS with Let's Encrypt
|
|
||||||
|
|
||||||
Use certbot with nginx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo certbot --nginx -d awards.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Checklist
|
|
||||||
|
|
||||||
- [ ] Set strong `JWT_SECRET`
|
|
||||||
- [ ] Set `NODE_ENV=production`
|
|
||||||
- [ ] Set `LOG_LEVEL=info` (or `warn` in production)
|
|
||||||
- [ ] Configure `ALLOWED_ORIGINS` to your domain only
|
|
||||||
- [ ] Use HTTPS/TLS in production
|
|
||||||
- [ ] Regular database backups
|
|
||||||
- [ ] Monitor logs for suspicious activity
|
|
||||||
- [ ] Keep Docker image updated
|
|
||||||
|
|
||||||
## File Structure After Deployment
|
|
||||||
|
|
||||||
```
|
|
||||||
project/
|
|
||||||
├── data/
|
|
||||||
│ └── award.db # Persisted database (volume mount)
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── .dockerignore
|
|
||||||
├── .env # Your environment variables
|
|
||||||
└── ... (source code)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building Without docker-compose
|
|
||||||
|
|
||||||
If you prefer to use `docker` directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -t quickawards .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -d \
|
|
||||||
--name quickawards \
|
|
||||||
-p 3001:3001 \
|
|
||||||
-v $(pwd)/data:/data \
|
|
||||||
-e JWT_SECRET=your-secret-here \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
quickawards
|
|
||||||
```
|
|
||||||
72
Dockerfile
72
Dockerfile
@@ -1,72 +0,0 @@
|
|||||||
# Multi-stage Dockerfile for Quickawards
|
|
||||||
# Uses official Bun runtime image
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 1: Dependencies & Database Init
|
|
||||||
# ============================================
|
|
||||||
FROM oven/bun:1 AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install ALL dependencies (including devDependencies for drizzle-kit)
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Copy source code (node_modules excluded by .dockerignore)
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Reinstall frontend dependencies to get correct platform binaries
|
|
||||||
RUN cd src/frontend && bun install
|
|
||||||
|
|
||||||
# Initialize database using custom script
|
|
||||||
# This creates a fresh database with the correct schema using bun:sqlite
|
|
||||||
RUN bun src/backend/scripts/init-db.js
|
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 2: Production Image
|
|
||||||
# ============================================
|
|
||||||
FROM oven/bun:1 AS production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install production dependencies only
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile --production
|
|
||||||
|
|
||||||
# Copy backend source and schema files
|
|
||||||
COPY src/backend ./src/backend
|
|
||||||
COPY award-definitions ./award-definitions
|
|
||||||
COPY drizzle.config.ts ./
|
|
||||||
|
|
||||||
# Copy frontend build from builder stage
|
|
||||||
COPY --from=builder /app/src/frontend/build ./src/frontend/build
|
|
||||||
|
|
||||||
# Copy initialized database from builder (will be used as template)
|
|
||||||
COPY --from=builder /app/src/backend/award.db /app/award.db.template
|
|
||||||
|
|
||||||
# Copy drizzle migrations (if they exist)
|
|
||||||
COPY --from=builder /app/drizzle ./drizzle
|
|
||||||
|
|
||||||
# Create directory for database volume mount
|
|
||||||
RUN mkdir -p /data
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
PORT=3001 \
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Expose the application port
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
# Use entrypoint script to handle database initialization
|
|
||||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
|
||||||
|
|
||||||
# Start the backend server
|
|
||||||
CMD ["bun", "run", "src/backend/index.js"]
|
|
||||||
77
README.md
77
README.md
@@ -116,7 +116,7 @@ award/
|
|||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── award-definitions/ # Award rule definitions (JSON)
|
├── award-definitions/ # Award rule definitions (JSON)
|
||||||
├── award.db # SQLite database (auto-created)
|
├── award.db # SQLite database (auto-created)
|
||||||
├── .env.production.template # Production configuration template
|
├── .env.example # Environment configuration template
|
||||||
├── bunfig.toml # Bun configuration
|
├── bunfig.toml # Bun configuration
|
||||||
├── drizzle.config.js # Drizzle ORM configuration
|
├── drizzle.config.js # Drizzle ORM configuration
|
||||||
├── package.json
|
├── package.json
|
||||||
@@ -149,20 +149,32 @@ cp .env.example .env
|
|||||||
|
|
||||||
Edit `.env` with your configuration:
|
Edit `.env` with your configuration:
|
||||||
```env
|
```env
|
||||||
# Application URL (for production deployment)
|
# Environment (development/production)
|
||||||
VITE_APP_URL=https://awards.dj7nt.de
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Log Level (debug/info/warn/error)
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Server Port (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Frontend URL (e.g., https://awards.dj7nt.de)
|
||||||
|
# Leave empty for development (uses localhost)
|
||||||
|
VITE_APP_URL=
|
||||||
|
|
||||||
# API Base URL (leave empty for same-domain deployment)
|
# API Base URL (leave empty for same-domain deployment)
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -base64 32)
|
# Allowed CORS origins (comma-separated)
|
||||||
JWT_SECRET=your-generated-secret-here
|
# Add all domains that should access the API
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# Environment
|
# JWT Secret (generate with: openssl rand -base64 32)
|
||||||
NODE_ENV=production
|
JWT_SECRET=change-this-in-production
|
||||||
```
|
```
|
||||||
|
|
||||||
**For development**: You can leave `.env` empty or use defaults.
|
**For development**: Use defaults above.
|
||||||
|
**For production**: Set `NODE_ENV=production`, `LOG_LEVEL=info`, and generate a strong `JWT_SECRET`.
|
||||||
|
|
||||||
4. Initialize the database with performance indexes:
|
4. Initialize the database with performance indexes:
|
||||||
```bash
|
```bash
|
||||||
@@ -246,6 +258,7 @@ The application will be available at:
|
|||||||
|
|
||||||
### Awards
|
### Awards
|
||||||
- `GET /api/awards` - Get all available awards
|
- `GET /api/awards` - Get all available awards
|
||||||
|
- `GET /api/awards/:awardId` - Get single award definition (includes mode groups)
|
||||||
- `GET /api/awards/batch/progress` - Get progress for all awards (optimized, single request)
|
- `GET /api/awards/batch/progress` - Get progress for all awards (optimized, single request)
|
||||||
- `GET /api/awards/:awardId/progress` - Get award progress for a specific award
|
- `GET /api/awards/:awardId/progress` - Get award progress for a specific award
|
||||||
- `GET /api/awards/:awardId/entities` - Get entity breakdown
|
- `GET /api/awards/:awardId/entities` - Get entity breakdown
|
||||||
@@ -414,20 +427,26 @@ bun run build
|
|||||||
Create `.env` in the project root:
|
Create `.env` in the project root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Application URL
|
|
||||||
VITE_APP_URL=https://awards.dj7nt.de
|
|
||||||
|
|
||||||
# API Base URL (empty for same-domain)
|
|
||||||
VITE_API_BASE_URL=
|
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -base64 32)
|
|
||||||
JWT_SECRET=your-generated-secret-here
|
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# Database path (absolute path recommended)
|
# Log Level (debug/info/warn/error)
|
||||||
DATABASE_PATH=/path/to/award/award.db
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Server Port (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Frontend URL
|
||||||
|
VITE_APP_URL=https://awards.dj7nt.de
|
||||||
|
|
||||||
|
# API Base URL (leave empty for same-domain deployment)
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Allowed CORS origins (comma-separated)
|
||||||
|
ALLOWED_ORIGINS=https://awards.dj7nt.de,https://www.awards.dj7nt.de
|
||||||
|
|
||||||
|
# JWT Secret (generate with: openssl rand -base64 32)
|
||||||
|
JWT_SECRET=your-generated-secret-here
|
||||||
```
|
```
|
||||||
|
|
||||||
**Security**: Ensure `.env` has restricted permissions:
|
**Security**: Ensure `.env` has restricted permissions:
|
||||||
@@ -782,6 +801,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:
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "dld-40m",
|
|
||||||
"name": "DLD 40m",
|
|
||||||
"description": "Confirm 100 unique DOKs on 40m",
|
|
||||||
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 40m band. Only DCL-confirmed QSOs with valid DOK information on 40m count toward this award.",
|
|
||||||
"category": "darc",
|
|
||||||
"rules": {
|
|
||||||
"type": "dok",
|
|
||||||
"target": 100,
|
|
||||||
"confirmationType": "dcl",
|
|
||||||
"displayField": "darcDok",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "band", "operator": "eq", "value": "40m" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "dld-80m-cw",
|
|
||||||
"name": "DLD 80m CW",
|
|
||||||
"description": "Confirm 100 unique DOKs on 80m using CW",
|
|
||||||
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band using CW mode. Only DCL-confirmed QSOs with valid DOK information on 80m CW count toward this award.",
|
|
||||||
"category": "darc",
|
|
||||||
"rules": {
|
|
||||||
"type": "dok",
|
|
||||||
"target": 100,
|
|
||||||
"confirmationType": "dcl",
|
|
||||||
"displayField": "darcDok",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "band", "operator": "eq", "value": "80m" },
|
|
||||||
{ "field": "mode", "operator": "eq", "value": "CW" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "dld-80m",
|
|
||||||
"name": "DLD 80m",
|
|
||||||
"description": "Confirm 100 unique DOKs on 80m",
|
|
||||||
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) on the 80m band. Only DCL-confirmed QSOs with valid DOK information on 80m count toward this award.",
|
|
||||||
"category": "darc",
|
|
||||||
"rules": {
|
|
||||||
"type": "dok",
|
|
||||||
"target": 100,
|
|
||||||
"confirmationType": "dcl",
|
|
||||||
"displayField": "darcDok",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "band", "operator": "eq", "value": "80m" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "dld-cw",
|
|
||||||
"name": "DLD CW",
|
|
||||||
"description": "Confirm 100 unique DOKs using CW mode",
|
|
||||||
"caption": "Contact and confirm stations with 100 unique DOKs (DARC Ortsverband Kennung) using CW (Morse code). Each unique DOK on CW counts separately. Only DCL-confirmed QSOs with valid DOK information count toward this award.",
|
|
||||||
"category": "darc",
|
|
||||||
"rules": {
|
|
||||||
"type": "dok",
|
|
||||||
"target": 100,
|
|
||||||
"confirmationType": "dcl",
|
|
||||||
"displayField": "darcDok",
|
|
||||||
"filters": {
|
|
||||||
"operator": "AND",
|
|
||||||
"filters": [
|
|
||||||
{ "field": "mode", "operator": "eq", "value": "CW" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,33 @@
|
|||||||
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"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",
|
||||||
|
"achievements": [
|
||||||
|
{ "name": "WAS Award", "threshold": 50 }
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "entity",
|
"type": "entity",
|
||||||
"entityType": "state",
|
"entityType": "state",
|
||||||
@@ -14,8 +17,8 @@
|
|||||||
"filters": [
|
"filters": [
|
||||||
{
|
{
|
||||||
"field": "entityId",
|
"field": "entityId",
|
||||||
"operator": "eq",
|
"operator": "in",
|
||||||
"value": 291
|
"value": [291, 6, 110]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
services:
|
|
||||||
quickawards:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: quickawards
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
environment:
|
|
||||||
# Application settings
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3001
|
|
||||||
LOG_LEVEL: info
|
|
||||||
|
|
||||||
# Security - IMPORTANT: Change these in production!
|
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-this-in-production}
|
|
||||||
|
|
||||||
# CORS - Set to your domain in production
|
|
||||||
VITE_APP_URL: ${VITE_APP_URL:-}
|
|
||||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
|
||||||
volumes:
|
|
||||||
# Host-mounted database directory
|
|
||||||
# Database will be created at ./data/award.db on first startup
|
|
||||||
- ./data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Docker container entrypoint script
|
|
||||||
# Handles database initialization on first startup
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Quickawards - Docker Entrypoint"
|
|
||||||
echo "=========================================="
|
|
||||||
|
|
||||||
# Database location in volume mount
|
|
||||||
DB_PATH="/data/award.db"
|
|
||||||
TEMPLATE_DB="/app/award.db.template"
|
|
||||||
APP_DB_PATH="/app/src/backend/award.db"
|
|
||||||
|
|
||||||
# Check if database exists in the volume
|
|
||||||
if [ ! -f "$DB_PATH" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "📦 Database not found in volume mount."
|
|
||||||
echo " Initializing from template database..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Copy the template database (created during build with drizzle-kit push)
|
|
||||||
cp "$TEMPLATE_DB" "$DB_PATH"
|
|
||||||
|
|
||||||
# Ensure proper permissions
|
|
||||||
chmod 644 "$DB_PATH"
|
|
||||||
|
|
||||||
echo "✅ Database initialized at: $DB_PATH"
|
|
||||||
echo " This database will persist in the Docker volume."
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "✅ Existing database found at: $DB_PATH"
|
|
||||||
echo " Using existing database from volume mount."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create symlink from app's expected db location to volume mount
|
|
||||||
# The app expects the database at src/backend/award.db
|
|
||||||
# We create a symlink so it points to the volume-mounted database
|
|
||||||
if [ -L "$APP_DB_PATH" ]; then
|
|
||||||
# Symlink already exists, remove it to refresh
|
|
||||||
rm "$APP_DB_PATH"
|
|
||||||
elif [ -e "$APP_DB_PATH" ]; then
|
|
||||||
# File or directory exists (shouldn't happen in production, but handle it)
|
|
||||||
echo "⚠ Warning: Found existing database at $APP_DB_PATH, removing..."
|
|
||||||
rm -f "$APP_DB_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create symlink to the volume-mounted database
|
|
||||||
ln -s "$DB_PATH" "$APP_DB_PATH"
|
|
||||||
echo "✅ Created symlink: $APP_DB_PATH -> $DB_PATH"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Starting Quickawards application..."
|
|
||||||
echo "Port: ${PORT:-3001}"
|
|
||||||
echo "Environment: ${NODE_ENV:-production}"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Execute the main command (passed as CMD in Dockerfile)
|
|
||||||
exec "$@"
|
|
||||||
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
@@ -10,6 +10,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|||||||
* @property {string|null} lotwPassword
|
* @property {string|null} lotwPassword
|
||||||
* @property {string|null} dclApiKey
|
* @property {string|null} dclApiKey
|
||||||
* @property {boolean} isAdmin
|
* @property {boolean} isAdmin
|
||||||
|
* @property {Date|null} lastSeen
|
||||||
* @property {Date} createdAt
|
* @property {Date} createdAt
|
||||||
* @property {Date} updatedAt
|
* @property {Date} updatedAt
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +24,7 @@ export const users = sqliteTable('users', {
|
|||||||
lotwPassword: text('lotw_password'), // Encrypted
|
lotwPassword: text('lotw_password'), // Encrypted
|
||||||
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
||||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
lastSeen: integer('last_seen', { mode: 'timestamp' }),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
@@ -223,5 +225,39 @@ export const adminActions = sqliteTable('admin_actions', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AutoSyncSettings
|
||||||
|
* @property {number} userId
|
||||||
|
* @property {boolean} lotwEnabled
|
||||||
|
* @property {number} lotwIntervalHours
|
||||||
|
* @property {Date|null} lotwLastSyncAt
|
||||||
|
* @property {Date|null} lotwNextSyncAt
|
||||||
|
* @property {boolean} dclEnabled
|
||||||
|
* @property {number} dclIntervalHours
|
||||||
|
* @property {Date|null} dclLastSyncAt
|
||||||
|
* @property {Date|null} dclNextSyncAt
|
||||||
|
* @property {Date} createdAt
|
||||||
|
* @property {Date} updatedAt
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const autoSyncSettings = sqliteTable('auto_sync_settings', {
|
||||||
|
userId: integer('user_id').primaryKey().references(() => users.id),
|
||||||
|
|
||||||
|
// LoTW auto-sync settings
|
||||||
|
lotwEnabled: integer('lotw_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
lotwIntervalHours: integer('lotw_interval_hours').notNull().default(24),
|
||||||
|
lotwLastSyncAt: integer('lotw_last_sync_at', { mode: 'timestamp' }),
|
||||||
|
lotwNextSyncAt: integer('lotw_next_sync_at', { mode: 'timestamp' }),
|
||||||
|
|
||||||
|
// DCL auto-sync settings
|
||||||
|
dclEnabled: integer('dcl_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
dclIntervalHours: integer('dcl_interval_hours').notNull().default(24),
|
||||||
|
dclLastSyncAt: integer('dcl_last_sync_at', { mode: 'timestamp' }),
|
||||||
|
dclNextSyncAt: integer('dcl_next_sync_at', { mode: 'timestamp' }),
|
||||||
|
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
// Export all schemas
|
// Export all schemas
|
||||||
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions };
|
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions, autoSyncSettings };
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
updateLoTWCredentials,
|
updateLoTWCredentials,
|
||||||
updateDCLCredentials,
|
updateDCLCredentials,
|
||||||
|
updateLastSeen,
|
||||||
} from './services/auth.service.js';
|
} from './services/auth.service.js';
|
||||||
import {
|
import {
|
||||||
getSystemStats,
|
getSystemStats,
|
||||||
getUserStats,
|
getUserStats,
|
||||||
|
getAdminActions,
|
||||||
impersonateUser,
|
impersonateUser,
|
||||||
verifyImpersonation,
|
verifyImpersonation,
|
||||||
stopImpersonation,
|
stopImpersonation,
|
||||||
@@ -39,9 +41,28 @@ import {
|
|||||||
} from './services/job-queue.service.js';
|
} from './services/job-queue.service.js';
|
||||||
import {
|
import {
|
||||||
getAllAwards,
|
getAllAwards,
|
||||||
|
getAwardById,
|
||||||
getAwardProgressDetails,
|
getAwardProgressDetails,
|
||||||
getAwardEntityBreakdown,
|
getAwardEntityBreakdown,
|
||||||
} from './services/awards.service.js';
|
} from './services/awards.service.js';
|
||||||
|
import {
|
||||||
|
getAllAwardDefinitions,
|
||||||
|
getAwardDefinition,
|
||||||
|
createAwardDefinition,
|
||||||
|
updateAwardDefinition,
|
||||||
|
deleteAwardDefinition,
|
||||||
|
testAwardCalculation,
|
||||||
|
} from './services/awards-admin.service.js';
|
||||||
|
import {
|
||||||
|
getAutoSyncSettings,
|
||||||
|
updateAutoSyncSettings,
|
||||||
|
} from './services/auto-sync.service.js';
|
||||||
|
import {
|
||||||
|
startScheduler,
|
||||||
|
stopScheduler,
|
||||||
|
getSchedulerStatus,
|
||||||
|
triggerSchedulerTick,
|
||||||
|
} from './services/scheduler.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main backend application
|
* Main backend application
|
||||||
@@ -187,6 +208,14 @@ const app = new Elysia()
|
|||||||
return { user: null };
|
return { user: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update last_seen timestamp asynchronously (don't await)
|
||||||
|
updateLastSeen(payload.userId).catch((err) => {
|
||||||
|
// Silently fail - last_seen update failure shouldn't block requests
|
||||||
|
if (LOG_LEVEL === 'debug') {
|
||||||
|
logger.warn('Failed to update last_seen', { error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check if this is an impersonation token
|
// Check if this is an impersonation token
|
||||||
const isImpersonation = !!payload.impersonatedBy;
|
const isImpersonation = !!payload.impersonatedBy;
|
||||||
|
|
||||||
@@ -434,9 +463,15 @@ const app = new Elysia()
|
|||||||
return { success: false, error: 'User not found' };
|
return { success: false, error: 'User not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include impersonatedBy from JWT if present (not stored in database)
|
||||||
|
const responseUser = {
|
||||||
|
...userData,
|
||||||
|
impersonatedBy: user.impersonatedBy,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user: userData,
|
user: responseUser,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -860,6 +895,7 @@ const app = new Elysia()
|
|||||||
message: `Deleted ${deleted} QSO(s)`,
|
message: `Deleted ${deleted} QSO(s)`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete QSOs', { error: error.message, stack: error.stack });
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -895,6 +931,42 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/awards/:awardId
|
||||||
|
* Get a single award by ID (requires authentication)
|
||||||
|
*/
|
||||||
|
.get('/api/awards/:awardId', async ({ user, params, set }) => {
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { success: false, error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { awardId } = params;
|
||||||
|
const award = getAwardById(awardId);
|
||||||
|
|
||||||
|
if (!award) {
|
||||||
|
set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Award not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching award', { error: error.message });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch award',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/awards/:awardId/progress
|
* GET /api/awards/:awardId/progress
|
||||||
* Get award progress for user (requires authentication)
|
* Get award progress for user (requires authentication)
|
||||||
@@ -1390,6 +1462,348 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================================================
|
||||||
|
* AWARD MANAGEMENT ROUTES (Admin Only)
|
||||||
|
* ================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/awards
|
||||||
|
* Get all award definitions (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/awards', async ({ user, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const awards = await getAllAwardDefinitions();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
awards,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching award definitions', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch award definitions',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/awards/:id
|
||||||
|
* Get a single award definition (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/awards/:id', async ({ user, params, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const award = await getAwardDefinition(params.id);
|
||||||
|
|
||||||
|
if (!award) {
|
||||||
|
set.status = 404;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Award not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching award definition', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch award definition',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/awards
|
||||||
|
* Create a new award definition (admin only)
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/api/admin/awards',
|
||||||
|
async ({ user, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const award = await createAwardDefinition(body);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
message: 'Award definition created successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating award definition', { error: error.message, userId: user.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
name: t.String(),
|
||||||
|
description: t.String(),
|
||||||
|
caption: t.String(),
|
||||||
|
category: t.String(),
|
||||||
|
rules: t.Any(),
|
||||||
|
modeGroups: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/awards/:id
|
||||||
|
* Update an award definition (admin only)
|
||||||
|
*/
|
||||||
|
.put(
|
||||||
|
'/api/admin/awards/:id',
|
||||||
|
async ({ user, params, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const award = await updateAwardDefinition(params.id, body);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
award,
|
||||||
|
message: 'Award definition updated successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Optional(t.String()),
|
||||||
|
name: t.String(),
|
||||||
|
description: t.String(),
|
||||||
|
caption: t.String(),
|
||||||
|
category: t.String(),
|
||||||
|
rules: t.Any(),
|
||||||
|
modeGroups: t.Optional(t.Any()),
|
||||||
|
achievements: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/awards/:id
|
||||||
|
* Delete an award definition (admin only)
|
||||||
|
*/
|
||||||
|
.delete('/api/admin/awards/:id', async ({ user, params, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteAwardDefinition(params.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
message: 'Award definition deleted successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/awards/:id/test
|
||||||
|
* Test award calculation (admin only)
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/api/admin/awards/:id/test',
|
||||||
|
async ({ user, params, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use provided userId or admin's own account
|
||||||
|
const testUserId = body.userId || user.id;
|
||||||
|
const awardDefinition = body.awardDefinition || null;
|
||||||
|
const result = await testAwardCalculation(params.id, testUserId, awardDefinition);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
userId: t.Optional(t.Integer()),
|
||||||
|
awardDefinition: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================================================
|
||||||
|
* AUTO-SYNC SETTINGS ROUTES
|
||||||
|
* ================================================================
|
||||||
|
* All auto-sync routes require authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auto-sync/settings
|
||||||
|
* Get user's auto-sync settings (requires authentication)
|
||||||
|
*/
|
||||||
|
.get('/api/auto-sync/settings', async ({ user, set }) => {
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { success: false, error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await getAutoSyncSettings(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
settings,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching auto-sync settings', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch auto-sync settings',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/auto-sync/settings
|
||||||
|
* Update user's auto-sync settings (requires authentication)
|
||||||
|
*/
|
||||||
|
.put(
|
||||||
|
'/api/auto-sync/settings',
|
||||||
|
async ({ user, body, set }) => {
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { success: false, error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await updateAutoSyncSettings(user.id, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
settings,
|
||||||
|
message: 'Auto-sync settings updated successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating auto-sync settings', { error: error.message, userId: user.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
lotwEnabled: t.Optional(t.Boolean()),
|
||||||
|
lotwIntervalHours: t.Optional(t.Number()),
|
||||||
|
dclEnabled: t.Optional(t.Boolean()),
|
||||||
|
dclIntervalHours: t.Optional(t.Number()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auto-sync/scheduler/status
|
||||||
|
* Get scheduler status (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/auto-sync/scheduler/status', async ({ user, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = getSchedulerStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
scheduler: status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching scheduler status', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch scheduler status',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auto-sync/scheduler/trigger
|
||||||
|
* Manually trigger scheduler tick (admin only, for testing)
|
||||||
|
*/
|
||||||
|
.post('/api/auto-sync/scheduler/trigger', async ({ user, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await triggerSchedulerTick();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Scheduler tick triggered successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error triggering scheduler tick', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to trigger scheduler tick',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Serve static files and SPA fallback for all non-API routes
|
// Serve static files and SPA fallback for all non-API routes
|
||||||
.get('/*', ({ request }) => {
|
.get('/*', ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -1546,3 +1960,21 @@ logger.info('Server started', {
|
|||||||
nodeEnv: process.env.NODE_ENV || 'unknown',
|
nodeEnv: process.env.NODE_ENV || 'unknown',
|
||||||
logLevel: LOG_LEVEL,
|
logLevel: LOG_LEVEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start the auto-sync scheduler
|
||||||
|
startScheduler();
|
||||||
|
|
||||||
|
// Graceful shutdown handlers
|
||||||
|
const gracefulShutdown = async (signal) => {
|
||||||
|
logger.info(`Received ${signal}, shutting down gracefully...`);
|
||||||
|
|
||||||
|
// Stop the scheduler
|
||||||
|
await stopScheduler();
|
||||||
|
|
||||||
|
logger.info('Graceful shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle shutdown signals
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -34,31 +34,35 @@ export async function logAdminAction(adminId, actionType, targetUserId = null, d
|
|||||||
* @returns {Promise<Array>} Array of admin actions
|
* @returns {Promise<Array>} Array of admin actions
|
||||||
*/
|
*/
|
||||||
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
||||||
let query = db
|
// Use raw SQL for the self-join (admin users and target users from same users table)
|
||||||
.select({
|
// Using bun:sqlite prepared statements for raw SQL
|
||||||
id: adminActions.id,
|
let query = `
|
||||||
adminId: adminActions.adminId,
|
SELECT
|
||||||
adminEmail: users.email,
|
aa.id as id,
|
||||||
adminCallsign: users.callsign,
|
aa.admin_id as adminId,
|
||||||
actionType: adminActions.actionType,
|
admin_user.email as adminEmail,
|
||||||
targetUserId: adminActions.targetUserId,
|
admin_user.callsign as adminCallsign,
|
||||||
targetEmail: sql`target_users.email`.as('targetEmail'),
|
aa.action_type as actionType,
|
||||||
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
|
aa.target_user_id as targetUserId,
|
||||||
details: adminActions.details,
|
target_user.email as targetEmail,
|
||||||
createdAt: adminActions.createdAt,
|
target_user.callsign as targetCallsign,
|
||||||
})
|
aa.details as details,
|
||||||
.from(adminActions)
|
aa.created_at as createdAt
|
||||||
.leftJoin(users, eq(adminActions.adminId, users.id))
|
FROM admin_actions aa
|
||||||
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
LEFT JOIN users admin_user ON admin_user.id = aa.admin_id
|
||||||
.orderBy(desc(adminActions.createdAt))
|
LEFT JOIN users target_user ON target_user.id = aa.target_user_id
|
||||||
.limit(limit)
|
`;
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
if (adminId) {
|
const params = [];
|
||||||
query = query.where(eq(adminActions.adminId, adminId));
|
if (adminId !== null) {
|
||||||
|
query += ` WHERE aa.admin_id = ?`;
|
||||||
|
params.push(adminId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query;
|
query += ` ORDER BY aa.created_at DESC LIMIT ? OFFSET ?`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
return sqlite.prepare(query).all(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,11 +127,17 @@ export async function getUserStats() {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
callsign: users.callsign,
|
callsign: users.callsign,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
|
lastSeen: users.lastSeen,
|
||||||
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
|
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
|
||||||
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${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`MAX(${qsos.createdAt})`,
|
lastSync: sql`(
|
||||||
|
SELECT MAX(${syncJobs.completedAt})
|
||||||
|
FROM ${syncJobs}
|
||||||
|
WHERE ${syncJobs.userId} = ${users.id}
|
||||||
|
AND ${syncJobs.status} = 'completed'
|
||||||
|
)`.mapWith(Number),
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -135,7 +145,14 @@ export async function getUserStats() {
|
|||||||
.groupBy(users.id)
|
.groupBy(users.id)
|
||||||
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
||||||
|
|
||||||
return stats;
|
// 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
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,24 +245,26 @@ export async function stopImpersonation(adminId, targetUserId) {
|
|||||||
* @returns {Promise<Array>} Array of recent impersonation actions
|
* @returns {Promise<Array>} Array of recent impersonation actions
|
||||||
*/
|
*/
|
||||||
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
||||||
const impersonations = await db
|
// Use raw SQL for the self-join to avoid Drizzle alias issues
|
||||||
.select({
|
// Using bun:sqlite prepared statements for raw SQL
|
||||||
id: adminActions.id,
|
const query = `
|
||||||
actionType: adminActions.actionType,
|
SELECT
|
||||||
targetUserId: adminActions.targetUserId,
|
aa.id as id,
|
||||||
targetEmail: sql`target_users.email`,
|
aa.action_type as actionType,
|
||||||
targetCallsign: sql`target_users.callsign`,
|
aa.target_user_id as targetUserId,
|
||||||
details: adminActions.details,
|
u.email as targetEmail,
|
||||||
createdAt: adminActions.createdAt,
|
u.callsign as targetCallsign,
|
||||||
})
|
aa.details as details,
|
||||||
.from(adminActions)
|
aa.created_at as createdAt
|
||||||
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
FROM admin_actions aa
|
||||||
.where(eq(adminActions.adminId, adminId))
|
LEFT JOIN users u ON u.id = aa.target_user_id
|
||||||
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
|
WHERE aa.admin_id = ?
|
||||||
.orderBy(desc(adminActions.createdAt))
|
AND aa.action_type LIKE 'impersonate%'
|
||||||
.limit(limit);
|
ORDER BY aa.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`;
|
||||||
|
|
||||||
return impersonations;
|
return sqlite.prepare(query).all(adminId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export async function getAllUsers() {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
callsign: users.callsign,
|
callsign: users.callsign,
|
||||||
isAdmin: users.isAdmin,
|
isAdmin: users.isAdmin,
|
||||||
|
lastSeen: users.lastSeen,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
updatedAt: users.updatedAt,
|
updatedAt: users.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -236,3 +237,17 @@ export async function getUserByIdFull(userId) {
|
|||||||
|
|
||||||
return user || null;
|
return user || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user's last seen timestamp
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function updateLastSeen(userId) {
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
lastSeen: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
}
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db, logger } from '../config.js';
|
import { db, logger } from '../config.js';
|
||||||
import { qsos } from '../db/schema/index.js';
|
import { qsos } from '../db/schema/index.js';
|
||||||
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync, readdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js';
|
import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.js';
|
||||||
|
|
||||||
@@ -13,27 +13,24 @@ import { getCachedAwardProgress, setCachedAwardProgress } from './cache.service.
|
|||||||
// Load award definitions from files
|
// Load award definitions from files
|
||||||
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
|
const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions');
|
||||||
|
|
||||||
|
// In-memory cache for award definitions (static, never changes at runtime)
|
||||||
|
let cachedAwardDefinitions = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all award definitions
|
* Load all award definitions (cached in memory)
|
||||||
*/
|
*/
|
||||||
function loadAwardDefinitions() {
|
function loadAwardDefinitions() {
|
||||||
|
// Return cached definitions if available
|
||||||
|
if (cachedAwardDefinitions) {
|
||||||
|
return cachedAwardDefinitions;
|
||||||
|
}
|
||||||
|
|
||||||
const definitions = [];
|
const definitions = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = [
|
// Auto-discover all JSON files in the award-definitions directory
|
||||||
'dxcc.json',
|
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
||||||
'dxcc-cw.json',
|
.filter(f => f.endsWith('.json'));
|
||||||
'was.json',
|
|
||||||
'vucc-sat.json',
|
|
||||||
'sat-rs44.json',
|
|
||||||
'special-stations.json',
|
|
||||||
'dld.json',
|
|
||||||
'dld-80m.json',
|
|
||||||
'dld-40m.json',
|
|
||||||
'dld-cw.json',
|
|
||||||
'dld-80m-cw.json',
|
|
||||||
'73-on-73.json',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
@@ -49,9 +46,102 @@ function loadAwardDefinitions() {
|
|||||||
logger.error('Error loading award definitions', { error: error.message });
|
logger.error('Error loading award definitions', { error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by award name with numeric prefixes in numerical order
|
||||||
|
definitions.sort((a, b) => {
|
||||||
|
const nameA = a.name || '';
|
||||||
|
const nameB = b.name || '';
|
||||||
|
|
||||||
|
// Extract leading numbers if present
|
||||||
|
const matchA = nameA.match(/^(\d+)/);
|
||||||
|
const matchB = nameB.match(/^(\d+)/);
|
||||||
|
|
||||||
|
// If both start with numbers, compare numerically first
|
||||||
|
if (matchA && matchB) {
|
||||||
|
const numA = parseInt(matchA[1], 10);
|
||||||
|
const numB = parseInt(matchB[1], 10);
|
||||||
|
if (numA !== numB) {
|
||||||
|
return numA - numB;
|
||||||
|
}
|
||||||
|
// If numbers are equal, fall through to alphabetical
|
||||||
|
}
|
||||||
|
// If one starts with a number, it comes first
|
||||||
|
else if (matchA) return -1;
|
||||||
|
else if (matchB) return 1;
|
||||||
|
|
||||||
|
// Otherwise, alphabetical comparison (case-insensitive)
|
||||||
|
return nameA.toLowerCase().localeCompare(nameB.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the definitions for future calls
|
||||||
|
cachedAwardDefinitions = definitions;
|
||||||
|
|
||||||
return definitions;
|
return definitions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached award definitions
|
||||||
|
* Call this after creating, updating, or deleting award definitions
|
||||||
|
*/
|
||||||
|
export function clearAwardCache() {
|
||||||
|
cachedAwardDefinitions = null;
|
||||||
|
logger.info('Award cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate achievement progress for an award
|
||||||
|
* @param {number} currentCount - Current confirmed count (entities or points)
|
||||||
|
* @param {Array} achievements - Array of achievement definitions
|
||||||
|
* @returns {Object|null} Achievement progress info or null if no achievements defined
|
||||||
|
*/
|
||||||
|
function calculateAchievementProgress(currentCount, achievements) {
|
||||||
|
if (!achievements || achievements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort achievements by threshold
|
||||||
|
const sorted = [...achievements].sort((a, b) => a.threshold - b.threshold);
|
||||||
|
|
||||||
|
// Find earned achievements, current level, and next level
|
||||||
|
const earned = [];
|
||||||
|
let currentLevel = null;
|
||||||
|
let nextLevel = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const achievement = sorted[i];
|
||||||
|
if (currentCount >= achievement.threshold) {
|
||||||
|
earned.push(achievement);
|
||||||
|
currentLevel = achievement;
|
||||||
|
} else {
|
||||||
|
nextLevel = achievement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress toward next level
|
||||||
|
let progressPercent = 100;
|
||||||
|
let progressCurrent = currentCount;
|
||||||
|
let progressNeeded = 0;
|
||||||
|
|
||||||
|
if (nextLevel) {
|
||||||
|
const prevThreshold = currentLevel ? currentLevel.threshold : 0;
|
||||||
|
const range = nextLevel.threshold - prevThreshold;
|
||||||
|
const progressInLevel = currentCount - prevThreshold;
|
||||||
|
progressPercent = Math.round((progressInLevel / range) * 100);
|
||||||
|
progressNeeded = nextLevel.threshold - currentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
earned,
|
||||||
|
currentLevel,
|
||||||
|
nextLevel,
|
||||||
|
progressPercent,
|
||||||
|
progressCurrent,
|
||||||
|
progressNeeded,
|
||||||
|
totalAchievements: sorted.length,
|
||||||
|
earnedCount: earned.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available awards
|
* Get all available awards
|
||||||
*/
|
*/
|
||||||
@@ -65,9 +155,36 @@ export async function getAllAwards() {
|
|||||||
caption: def.caption,
|
caption: def.caption,
|
||||||
category: def.category,
|
category: def.category,
|
||||||
rules: def.rules,
|
rules: def.rules,
|
||||||
|
modeGroups: def.modeGroups || null,
|
||||||
|
achievements: def.achievements || null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single award by ID
|
||||||
|
* @param {string} awardId - Award ID
|
||||||
|
* @returns {Object|null} Award definition or null if not found
|
||||||
|
*/
|
||||||
|
export function getAwardById(awardId) {
|
||||||
|
const definitions = loadAwardDefinitions();
|
||||||
|
const award = definitions.find((def) => def.id === awardId);
|
||||||
|
|
||||||
|
if (!award) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: award.id,
|
||||||
|
name: award.name,
|
||||||
|
description: award.description,
|
||||||
|
caption: award.caption,
|
||||||
|
category: award.category,
|
||||||
|
rules: award.rules,
|
||||||
|
modeGroups: award.modeGroups || null,
|
||||||
|
achievements: award.achievements || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate award progress for a user
|
* Calculate award progress for a user
|
||||||
* @param {number} userId - User ID
|
* @param {number} userId - User ID
|
||||||
@@ -140,11 +257,27 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
logger.debug('QSOs after filters', { count: filteredQSOs.length });
|
logger.debug('QSOs after filters', { count: filteredQSOs.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply allowed_bands filter if present
|
||||||
|
let finalQSOs = filteredQSOs;
|
||||||
|
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
|
||||||
|
finalQSOs = filteredQSOs.filter(qso => {
|
||||||
|
const band = qso.band;
|
||||||
|
return rules.allowed_bands.includes(band);
|
||||||
|
});
|
||||||
|
logger.debug('QSOs after allowed_bands filter', { count: finalQSOs.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply satellite_only filter if present
|
||||||
|
if (rules.satellite_only) {
|
||||||
|
finalQSOs = finalQSOs.filter(qso => qso.satName);
|
||||||
|
logger.debug('QSOs after satellite_only filter', { count: finalQSOs.length });
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate worked and confirmed entities
|
// Calculate worked and confirmed entities
|
||||||
const workedEntities = new Set();
|
const workedEntities = new Set();
|
||||||
const confirmedEntities = new Set();
|
const confirmedEntities = new Set();
|
||||||
|
|
||||||
for (const qso of filteredQSOs) {
|
for (const qso of finalQSOs) {
|
||||||
const entity = getEntityValue(qso, rules.entityType);
|
const entity = getEntityValue(qso, rules.entityType);
|
||||||
|
|
||||||
if (entity) {
|
if (entity) {
|
||||||
@@ -158,7 +291,7 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
worked: workedEntities.size,
|
worked: workedEntities.size,
|
||||||
confirmed: confirmedEntities.size,
|
confirmed: confirmedEntities.size,
|
||||||
target: rules.target || 0,
|
target: rules.target || 0,
|
||||||
@@ -166,6 +299,13 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
|||||||
workedEntities: Array.from(workedEntities),
|
workedEntities: Array.from(workedEntities),
|
||||||
confirmedEntities: Array.from(confirmedEntities),
|
confirmedEntities: Array.from(confirmedEntities),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add achievement progress if award has achievements defined
|
||||||
|
if (award.achievements && award.achievements.length > 0) {
|
||||||
|
result.achievements = calculateAchievementProgress(confirmedEntities.size, award.achievements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,7 +339,7 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track unique (DOK, band, mode) combinations
|
// Track unique (DOK, band, mode) combinations
|
||||||
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object
|
const dokCombinations = new Map(); // Key: "DOK/band/mode" -> detail object with qsos array
|
||||||
|
|
||||||
for (const qso of filteredQSOs) {
|
for (const qso of filteredQSOs) {
|
||||||
const dok = qso.darcDok;
|
const dok = qso.darcDok;
|
||||||
@@ -212,29 +352,36 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
|
|||||||
// Initialize combination if not exists
|
// Initialize combination if not exists
|
||||||
if (!dokCombinations.has(combinationKey)) {
|
if (!dokCombinations.has(combinationKey)) {
|
||||||
dokCombinations.set(combinationKey, {
|
dokCombinations.set(combinationKey, {
|
||||||
qsoId: qso.id,
|
|
||||||
entity: dok,
|
entity: dok,
|
||||||
entityId: null,
|
entityId: null,
|
||||||
entityName: dok,
|
entityName: dok,
|
||||||
band,
|
band,
|
||||||
mode,
|
mode,
|
||||||
callsign: qso.callsign,
|
|
||||||
worked: false,
|
worked: false,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
qsoDate: qso.qsoDate,
|
qsos: [], // Array of confirmed QSOs for this slot
|
||||||
dclQslRdate: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = dokCombinations.get(combinationKey);
|
const detail = dokCombinations.get(combinationKey);
|
||||||
detail.worked = true;
|
detail.worked = true;
|
||||||
|
|
||||||
// Check for DCL confirmation
|
// Check for DCL confirmation and add to qsos array
|
||||||
if (qso.dclQslRstatus === 'Y') {
|
if (qso.dclQslRstatus === 'Y') {
|
||||||
if (!detail.confirmed) {
|
if (!detail.confirmed) {
|
||||||
detail.confirmed = true;
|
detail.confirmed = true;
|
||||||
detail.dclQslRdate = qso.dclQslRdate;
|
|
||||||
}
|
}
|
||||||
|
// Add this confirmed QSO to the qsos array
|
||||||
|
detail.qsos.push({
|
||||||
|
qsoId: qso.id,
|
||||||
|
callsign: qso.callsign,
|
||||||
|
mode: qso.mode,
|
||||||
|
qsoDate: qso.qsoDate,
|
||||||
|
timeOn: qso.timeOn,
|
||||||
|
band: qso.band,
|
||||||
|
satName: qso.satName,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +426,11 @@ async function calculateDOKAwardProgress(userId, award, options = {}) {
|
|||||||
result.confirmed = result.entities.filter((e) => e.confirmed).length;
|
result.confirmed = result.entities.filter((e) => e.confirmed).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add achievement progress if award has achievements defined
|
||||||
|
if (award.achievements && award.achievements.length > 0) {
|
||||||
|
result.achievements = calculateAchievementProgress(confirmedDOKs.size, award.achievements);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,15 +491,13 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
|
|
||||||
if (!combinationMap.has(combinationKey)) {
|
if (!combinationMap.has(combinationKey)) {
|
||||||
combinationMap.set(combinationKey, {
|
combinationMap.set(combinationKey, {
|
||||||
qsoId: qso.id,
|
|
||||||
callsign,
|
callsign,
|
||||||
band,
|
band,
|
||||||
mode,
|
mode,
|
||||||
points,
|
points,
|
||||||
worked: true,
|
worked: true,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
qsoDate: qso.qsoDate,
|
qsos: [], // Array of confirmed QSOs for this slot
|
||||||
lotwQslRdate: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,8 +505,18 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
const detail = combinationMap.get(combinationKey);
|
const detail = combinationMap.get(combinationKey);
|
||||||
if (!detail.confirmed) {
|
if (!detail.confirmed) {
|
||||||
detail.confirmed = true;
|
detail.confirmed = true;
|
||||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
|
||||||
}
|
}
|
||||||
|
// Add this confirmed QSO to the qsos array
|
||||||
|
detail.qsos.push({
|
||||||
|
qsoId: qso.id,
|
||||||
|
callsign: qso.callsign,
|
||||||
|
mode: qso.mode,
|
||||||
|
qsoDate: qso.qsoDate,
|
||||||
|
timeOn: qso.timeOn,
|
||||||
|
band: qso.band,
|
||||||
|
satName: qso.satName,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,15 +538,11 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
|
|
||||||
if (!stationMap.has(callsign)) {
|
if (!stationMap.has(callsign)) {
|
||||||
stationMap.set(callsign, {
|
stationMap.set(callsign, {
|
||||||
qsoId: qso.id,
|
|
||||||
callsign,
|
callsign,
|
||||||
points,
|
points,
|
||||||
worked: true,
|
worked: true,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
qsoDate: qso.qsoDate,
|
qsos: [], // Array of confirmed QSOs for this station
|
||||||
band: qso.band,
|
|
||||||
mode: qso.mode,
|
|
||||||
lotwQslRdate: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +550,18 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
const detail = stationMap.get(callsign);
|
const detail = stationMap.get(callsign);
|
||||||
if (!detail.confirmed) {
|
if (!detail.confirmed) {
|
||||||
detail.confirmed = true;
|
detail.confirmed = true;
|
||||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
|
||||||
}
|
}
|
||||||
|
// Add this confirmed QSO to the qsos array
|
||||||
|
detail.qsos.push({
|
||||||
|
qsoId: qso.id,
|
||||||
|
callsign: qso.callsign,
|
||||||
|
mode: qso.mode,
|
||||||
|
qsoDate: qso.qsoDate,
|
||||||
|
timeOn: qso.timeOn,
|
||||||
|
band: qso.band,
|
||||||
|
satName: qso.satName,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +581,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
|
|
||||||
if (qso.lotwQslRstatus === 'Y') {
|
if (qso.lotwQslRstatus === 'Y') {
|
||||||
totalPoints += points;
|
totalPoints += points;
|
||||||
|
// For perQso mode, each QSO is its own slot with a qsos array containing just itself
|
||||||
stationDetails.push({
|
stationDetails.push({
|
||||||
qsoId: qso.id,
|
qsoId: qso.id,
|
||||||
callsign,
|
callsign,
|
||||||
@@ -424,7 +591,16 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
qsoDate: qso.qsoDate,
|
qsoDate: qso.qsoDate,
|
||||||
band: qso.band,
|
band: qso.band,
|
||||||
mode: qso.mode,
|
mode: qso.mode,
|
||||||
lotwQslRdate: qso.lotwQslRdate,
|
qsos: [{
|
||||||
|
qsoId: qso.id,
|
||||||
|
callsign: qso.callsign,
|
||||||
|
mode: qso.mode,
|
||||||
|
qsoDate: qso.qsoDate,
|
||||||
|
timeOn: qso.timeOn,
|
||||||
|
band: qso.band,
|
||||||
|
satName: qso.satName,
|
||||||
|
confirmed: true,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,6 +641,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
mode: detail.mode,
|
mode: detail.mode,
|
||||||
callsign: detail.callsign,
|
callsign: detail.callsign,
|
||||||
lotwQslRdate: detail.lotwQslRdate,
|
lotwQslRdate: detail.lotwQslRdate,
|
||||||
|
qsos: detail.qsos || [], // All confirmed QSOs for this slot
|
||||||
};
|
};
|
||||||
} else if (countMode === 'perStation') {
|
} else if (countMode === 'perStation') {
|
||||||
return {
|
return {
|
||||||
@@ -480,6 +657,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
mode: detail.mode,
|
mode: detail.mode,
|
||||||
callsign: detail.callsign,
|
callsign: detail.callsign,
|
||||||
lotwQslRdate: detail.lotwQslRdate,
|
lotwQslRdate: detail.lotwQslRdate,
|
||||||
|
qsos: detail.qsos || [], // All confirmed QSOs for this station
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
@@ -495,6 +673,7 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
mode: detail.mode,
|
mode: detail.mode,
|
||||||
callsign: detail.callsign,
|
callsign: detail.callsign,
|
||||||
lotwQslRdate: detail.lotwQslRdate,
|
lotwQslRdate: detail.lotwQslRdate,
|
||||||
|
qsos: detail.qsos || [], // All confirmed QSOs for this slot (just this one QSO)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -513,6 +692,12 @@ async function calculatePointsAwardProgress(userId, award, options = {}) {
|
|||||||
result.stationDetails = stationDetails;
|
result.stationDetails = stationDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add achievement progress if award has achievements defined
|
||||||
|
// For point-based awards, use totalPoints instead of confirmed count
|
||||||
|
if (award.achievements && award.achievements.length > 0) {
|
||||||
|
result.achievements = calculateAchievementProgress(totalPoints, award.achievements);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,48 +860,77 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
|||||||
// Apply filters
|
// Apply filters
|
||||||
const filteredQSOs = applyFilters(allQSOs, rules.filters);
|
const filteredQSOs = applyFilters(allQSOs, rules.filters);
|
||||||
|
|
||||||
// Group by entity
|
// Apply allowed_bands filter if present
|
||||||
const entityMap = new Map();
|
let finalQSOs = filteredQSOs;
|
||||||
|
if (rules.allowed_bands && Array.isArray(rules.allowed_bands) && rules.allowed_bands.length > 0) {
|
||||||
|
finalQSOs = filteredQSOs.filter(qso => {
|
||||||
|
const band = qso.band;
|
||||||
|
return rules.allowed_bands.includes(band);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const qso of filteredQSOs) {
|
// Apply satellite_only filter if present
|
||||||
|
if (rules.satellite_only) {
|
||||||
|
finalQSOs = finalQSOs.filter(qso => qso.satName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by (entity, band, mode) slot for entity awards
|
||||||
|
// This allows showing multiple QSOs per entity on different bands/modes
|
||||||
|
const slotMap = new Map(); // Key: "entity/band/mode" -> slot object
|
||||||
|
|
||||||
|
for (const qso of finalQSOs) {
|
||||||
const entity = getEntityValue(qso, rules.entityType);
|
const entity = getEntityValue(qso, rules.entityType);
|
||||||
|
|
||||||
if (!entity) continue;
|
if (!entity) continue;
|
||||||
|
|
||||||
if (!entityMap.has(entity)) {
|
const band = qso.band || 'Unknown';
|
||||||
// Determine what to display as the entity name
|
const mode = qso.mode || 'Unknown';
|
||||||
let displayName = String(entity);
|
const slotKey = `${entity}/${band}/${mode}`;
|
||||||
if (rules.displayField) {
|
|
||||||
let rawValue = qso[rules.displayField];
|
|
||||||
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
|
|
||||||
rawValue = rawValue.substring(0, 4);
|
|
||||||
}
|
|
||||||
displayName = String(rawValue || entity);
|
|
||||||
} else {
|
|
||||||
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
entityMap.set(entity, {
|
// Determine what to display as the entity name (only on first create)
|
||||||
qsoId: qso.id,
|
let displayName = String(entity);
|
||||||
|
if (rules.displayField) {
|
||||||
|
let rawValue = qso[rules.displayField];
|
||||||
|
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
|
||||||
|
rawValue = rawValue.substring(0, 4);
|
||||||
|
}
|
||||||
|
displayName = String(rawValue || entity);
|
||||||
|
} else {
|
||||||
|
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slotMap.has(slotKey)) {
|
||||||
|
slotMap.set(slotKey, {
|
||||||
entity,
|
entity,
|
||||||
entityId: qso.entityId,
|
entityId: qso.entityId,
|
||||||
entityName: displayName,
|
entityName: displayName,
|
||||||
|
band,
|
||||||
|
mode,
|
||||||
worked: false,
|
worked: false,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
qsoDate: qso.qsoDate,
|
qsos: [], // Array of confirmed QSOs for this slot
|
||||||
band: qso.band,
|
|
||||||
mode: qso.mode,
|
|
||||||
callsign: qso.callsign,
|
|
||||||
satName: qso.satName,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityData = entityMap.get(entity);
|
const slotData = slotMap.get(slotKey);
|
||||||
entityData.worked = true;
|
slotData.worked = true;
|
||||||
|
|
||||||
|
// Check for LoTW confirmation and add to qsos array
|
||||||
if (qso.lotwQslRstatus === 'Y') {
|
if (qso.lotwQslRstatus === 'Y') {
|
||||||
entityData.confirmed = true;
|
if (!slotData.confirmed) {
|
||||||
entityData.lotwQslRdate = qso.lotwQslRdate;
|
slotData.confirmed = true;
|
||||||
|
}
|
||||||
|
// Add this confirmed QSO to the qsos array
|
||||||
|
slotData.qsos.push({
|
||||||
|
qsoId: qso.id,
|
||||||
|
callsign: qso.callsign,
|
||||||
|
mode: qso.mode,
|
||||||
|
qsoDate: qso.qsoDate,
|
||||||
|
timeOn: qso.timeOn,
|
||||||
|
band: qso.band,
|
||||||
|
satName: qso.satName,
|
||||||
|
confirmed: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,8 +942,8 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
|||||||
caption: award.caption,
|
caption: award.caption,
|
||||||
target: rules.target || 0,
|
target: rules.target || 0,
|
||||||
},
|
},
|
||||||
entities: Array.from(entityMap.values()),
|
entities: Array.from(slotMap.values()),
|
||||||
total: entityMap.size,
|
total: slotMap.size,
|
||||||
confirmed: Array.from(entityMap.values()).filter((e) => e.confirmed).length,
|
confirmed: Array.from(slotMap.values()).filter((e) => e.confirmed).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,32 +86,6 @@ export function clearAllCache() {
|
|||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache statistics (for monitoring/debugging)
|
|
||||||
* @returns {object} Cache stats
|
|
||||||
*/
|
|
||||||
export function getCacheStats() {
|
|
||||||
const now = Date.now();
|
|
||||||
let expired = 0;
|
|
||||||
let valid = 0;
|
|
||||||
|
|
||||||
for (const [, value] of awardCache) {
|
|
||||||
const age = now - value.timestamp;
|
|
||||||
if (age > CACHE_TTL) {
|
|
||||||
expired++;
|
|
||||||
} else {
|
|
||||||
valid++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: awardCache.size,
|
|
||||||
valid,
|
|
||||||
expired,
|
|
||||||
ttl: CACHE_TTL
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up expired cache entries (maintenance function)
|
* Clean up expired cache entries (maintenance function)
|
||||||
* Can be called periodically to free memory
|
* Can be called periodically to free memory
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { invalidateUserCache, invalidateStatsCache } from './cache.service.js';
|
||||||
|
import { yieldToEventLoop, getQSOKey } from '../utils/sync-helpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DCL (DARC Community Logbook) Service
|
* DCL (DARC Community Logbook) Service
|
||||||
@@ -122,17 +123,6 @@ export async function fetchQSOsFromDCL(dclApiKey, sinceDate = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse DCL API response from JSON
|
|
||||||
* Can be used for testing with example payloads
|
|
||||||
*
|
|
||||||
* @param {Object} jsonResponse - JSON response in DCL format
|
|
||||||
* @returns {Array} Array of parsed QSO records
|
|
||||||
*/
|
|
||||||
export function parseDCLJSONResponse(jsonResponse) {
|
|
||||||
return parseDCLResponse(jsonResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert DCL ADIF QSO to database format
|
* Convert DCL ADIF QSO to database format
|
||||||
* @param {Object} adifQSO - Parsed ADIF QSO record
|
* @param {Object} adifQSO - Parsed ADIF QSO record
|
||||||
@@ -169,21 +159,6 @@ function convertQSODatabaseFormat(adifQSO, userId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Yield to event loop to allow other requests to be processed
|
|
||||||
* This prevents blocking the server during long-running sync operations
|
|
||||||
*/
|
|
||||||
function yieldToEventLoop() {
|
|
||||||
return new Promise(resolve => setImmediate(resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get QSO key for duplicate detection
|
|
||||||
*/
|
|
||||||
function getQSOKey(qso) {
|
|
||||||
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync QSOs from DCL to database (optimized with batch operations)
|
* Sync QSOs from DCL to database (optimized with batch operations)
|
||||||
* Updates existing QSOs with DCL confirmation data
|
* Updates existing QSOs with DCL confirmation data
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { db, logger } from '../config.js';
|
import { db, logger } from '../config.js';
|
||||||
import { qsos, qsoChanges } from '../db/schema/index.js';
|
import { qsos, qsoChanges, syncJobs, awardProgress } from '../db/schema/index.js';
|
||||||
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
||||||
import { updateJobProgress } from './job-queue.service.js';
|
import { updateJobProgress } from './job-queue.service.js';
|
||||||
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
||||||
import { invalidateUserCache, getCachedStats, setCachedStats, invalidateStatsCache } from './cache.service.js';
|
import { invalidateUserCache, getCachedStats, setCachedStats, invalidateStatsCache } from './cache.service.js';
|
||||||
import { trackQueryPerformance, getPerformanceSummary, resetPerformanceMetrics } from './performance.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
|
||||||
@@ -81,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({
|
||||||
@@ -176,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.`
|
||||||
};
|
};
|
||||||
@@ -210,21 +212,6 @@ function convertQSODatabaseFormat(adifQSO, userId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Yield to event loop to allow other requests to be processed
|
|
||||||
* This prevents blocking the server during long-running sync operations
|
|
||||||
*/
|
|
||||||
function yieldToEventLoop() {
|
|
||||||
return new Promise(resolve => setImmediate(resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get QSO key for duplicate detection
|
|
||||||
*/
|
|
||||||
function getQSOKey(qso) {
|
|
||||||
return `${qso.callsign}|${qso.qsoDate}|${qso.timeOn}|${qso.band}|${qso.mode}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync QSOs from LoTW to database (optimized with batch operations)
|
* Sync QSOs from LoTW to database (optimized with batch operations)
|
||||||
* @param {number} userId - User ID
|
* @param {number} userId - User ID
|
||||||
@@ -609,10 +596,58 @@ export async function getLastLoTWQSLDate(userId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all QSOs for a user
|
* Delete all QSOs for a user
|
||||||
|
* Also deletes related qso_changes records to satisfy foreign key constraints
|
||||||
*/
|
*/
|
||||||
export async function deleteQSOs(userId) {
|
export async function deleteQSOs(userId) {
|
||||||
|
logger.debug('Deleting all QSOs for user', { userId });
|
||||||
|
|
||||||
|
// Step 1: Delete qso_changes that reference QSOs for this user
|
||||||
|
// Need to use a subquery since qso_changes doesn't have userId directly
|
||||||
|
const qsoIdsResult = await db
|
||||||
|
.select({ id: qsos.id })
|
||||||
|
.from(qsos)
|
||||||
|
.where(eq(qsos.userId, userId));
|
||||||
|
|
||||||
|
const qsoIds = qsoIdsResult.map(r => r.id);
|
||||||
|
|
||||||
|
let deletedChanges = 0;
|
||||||
|
if (qsoIds.length > 0) {
|
||||||
|
// Delete qso_changes where qsoId is in the list of QSO IDs
|
||||||
|
const changesResult = await db
|
||||||
|
.delete(qsoChanges)
|
||||||
|
.where(sql`${qsoChanges.qsoId} IN ${sql.raw(`(${qsoIds.join(',')})`)}`);
|
||||||
|
|
||||||
|
deletedChanges = changesResult.changes || changesResult || 0;
|
||||||
|
logger.debug('Deleted qso_changes', { count: deletedChanges });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Delete the QSOs
|
||||||
const result = await db.delete(qsos).where(eq(qsos.userId, userId));
|
const result = await db.delete(qsos).where(eq(qsos.userId, userId));
|
||||||
return result;
|
logger.debug('Delete result', { result, type: typeof result, keys: Object.keys(result || {}) });
|
||||||
|
|
||||||
|
// Drizzle with SQLite/bun:sqlite returns various formats depending on driver
|
||||||
|
let count = 0;
|
||||||
|
if (result) {
|
||||||
|
if (typeof result === 'number') {
|
||||||
|
count = result;
|
||||||
|
} else if (result.changes !== undefined) {
|
||||||
|
count = result.changes;
|
||||||
|
} else if (result.rows !== undefined) {
|
||||||
|
count = result.rows;
|
||||||
|
} else if (result.meta?.changes !== undefined) {
|
||||||
|
count = result.meta.changes;
|
||||||
|
} else if (result.meta?.rows !== undefined) {
|
||||||
|
count = result.meta.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Deleted QSOs', { userId, count, deletedChanges });
|
||||||
|
|
||||||
|
// Invalidate caches for this user
|
||||||
|
await invalidateStatsCache(userId);
|
||||||
|
await invalidateUserCache(userId);
|
||||||
|
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
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>
|
||||||
|
|||||||
@@ -118,3 +118,41 @@ export const adminAPI = {
|
|||||||
|
|
||||||
getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`),
|
getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-Sync API
|
||||||
|
export const autoSyncAPI = {
|
||||||
|
getSettings: () => apiRequest('/auto-sync/settings'),
|
||||||
|
|
||||||
|
updateSettings: (settings) => apiRequest('/auto-sync/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Awards Admin API
|
||||||
|
export const awardsAdminAPI = {
|
||||||
|
getAll: () => apiRequest('/admin/awards'),
|
||||||
|
|
||||||
|
getById: (id) => apiRequest(`/admin/awards/${id}`),
|
||||||
|
|
||||||
|
create: (data) => apiRequest('/admin/awards', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: (id, data) => apiRequest(`/admin/awards/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (id) => apiRequest(`/admin/awards/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId, awardDefinition }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -30,11 +71,32 @@
|
|||||||
{#if $auth.user?.isAdmin}
|
{#if $auth.user?.isAdmin}
|
||||||
<a href="/admin" class="nav-link admin-link">Admin</a>
|
<a href="/admin" class="nav-link admin-link">Admin</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
<ThemeSwitcher />
|
||||||
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
|
<button on:click={handleLogout} class="nav-link logout-btn">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -59,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 {
|
||||||
@@ -69,8 +132,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -84,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;
|
||||||
}
|
}
|
||||||
@@ -96,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;
|
||||||
@@ -109,27 +173,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: white;
|
opacity: 1;
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
color: #ff6b6b;
|
color: var(--color-logout);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
color: #ff5252;
|
background-color: var(--color-logout-bg);
|
||||||
background-color: rgba(255, 107, 107, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-link {
|
.admin-link {
|
||||||
background-color: #ffc107;
|
background-color: var(--color-admin-bg);
|
||||||
color: #000;
|
color: var(--color-admin-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-link:hover {
|
.admin-link:hover {
|
||||||
background-color: #e0a800;
|
background-color: var(--color-admin-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -141,8 +205,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -152,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: 1200px;
|
||||||
|
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,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { jobsAPI } from '$lib/api.js';
|
import { jobsAPI, autoSyncAPI } from '$lib/api.js';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let jobs = [];
|
let jobs = [];
|
||||||
@@ -9,6 +9,18 @@
|
|||||||
let cancellingJobs = new Map(); // Track cancelling state per job
|
let cancellingJobs = new Map(); // Track cancelling state per job
|
||||||
let pollingInterval = null;
|
let pollingInterval = null;
|
||||||
|
|
||||||
|
// Auto-sync settings state
|
||||||
|
let autoSyncSettings = null;
|
||||||
|
let loadingAutoSync = false;
|
||||||
|
|
||||||
|
// Reactive: scheduled jobs derived from settings
|
||||||
|
// Note: Explicitly reference autoSyncSettings to ensure Svelte tracks it as a dependency
|
||||||
|
let scheduledJobs = [];
|
||||||
|
$: {
|
||||||
|
autoSyncSettings; // Touch variable so Svelte tracks reactivity
|
||||||
|
scheduledJobs = getScheduledJobs();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadJobs() {
|
async function loadJobs() {
|
||||||
try {
|
try {
|
||||||
const response = await jobsAPI.getRecent(5);
|
const response = await jobsAPI.getRecent(5);
|
||||||
@@ -22,6 +34,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAutoSyncSettings() {
|
||||||
|
try {
|
||||||
|
loadingAutoSync = true;
|
||||||
|
const response = await autoSyncAPI.getSettings();
|
||||||
|
autoSyncSettings = response.settings || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load auto-sync settings:', error);
|
||||||
|
// Don't show error, auto-sync is optional
|
||||||
|
} finally {
|
||||||
|
loadingAutoSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduledJobs() {
|
||||||
|
if (!autoSyncSettings) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduled = [];
|
||||||
|
|
||||||
|
if (autoSyncSettings.lotwEnabled) {
|
||||||
|
scheduled.push({
|
||||||
|
type: 'lotw_sync',
|
||||||
|
icon: '📡',
|
||||||
|
name: 'LoTW Auto-Sync',
|
||||||
|
interval: autoSyncSettings.lotwIntervalHours,
|
||||||
|
nextSyncAt: autoSyncSettings.lotwNextSyncAt,
|
||||||
|
lastSyncAt: autoSyncSettings.lotwLastSyncAt,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSyncSettings.dclEnabled) {
|
||||||
|
scheduled.push({
|
||||||
|
type: 'dcl_sync',
|
||||||
|
icon: '🛰️',
|
||||||
|
name: 'DCL Auto-Sync',
|
||||||
|
interval: autoSyncSettings.dclIntervalHours,
|
||||||
|
nextSyncAt: autoSyncSettings.dclNextSyncAt,
|
||||||
|
lastSyncAt: autoSyncSettings.dclLastSyncAt,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextSyncLabel(nextSyncAt, interval) {
|
||||||
|
if (!nextSyncAt) return 'Pending...';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextSync = new Date(nextSyncAt);
|
||||||
|
const diffMs = nextSync - now;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMs < 0) return 'Due now';
|
||||||
|
if (diffMins < 60) return `In ${diffMins} minute${diffMins !== 1 ? 's' : ''}`;
|
||||||
|
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
|
||||||
|
return `In ${diffDays} day${diffDays !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextSyncTime(nextSyncAt) {
|
||||||
|
if (!nextSyncAt) return null;
|
||||||
|
const date = new Date(nextSyncAt);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSyncTime(lastSyncAt) {
|
||||||
|
if (!lastSyncAt) return 'Never';
|
||||||
|
const date = new Date(lastSyncAt);
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
function hasActiveJobs() {
|
function hasActiveJobs() {
|
||||||
return jobs.some(job => job.status === 'pending' || job.status === 'running');
|
return jobs.some(job => job.status === 'pending' || job.status === 'running');
|
||||||
}
|
}
|
||||||
@@ -58,6 +145,7 @@
|
|||||||
// Load recent jobs if authenticated
|
// Load recent jobs if authenticated
|
||||||
if ($auth.user) {
|
if ($auth.user) {
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
|
await loadAutoSyncSettings();
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -187,6 +275,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Auto-Sync Jobs -->
|
||||||
|
{#if scheduledJobs.length > 0}
|
||||||
|
<div class="scheduled-section">
|
||||||
|
<h2 class="section-title">⏰ Upcoming Auto-Sync</h2>
|
||||||
|
<div class="jobs-list">
|
||||||
|
{#each scheduledJobs as scheduled (scheduled.type)}
|
||||||
|
<div class="job-card job-card-scheduled">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title">
|
||||||
|
<span class="job-icon">{scheduled.icon}</span>
|
||||||
|
<span class="job-name">{scheduled.name}</span>
|
||||||
|
<span class="job-badge scheduled-badge">Scheduled</span>
|
||||||
|
</div>
|
||||||
|
<span class="scheduled-interval">Every {scheduled.interval}h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="job-meta">
|
||||||
|
<span class="job-date">
|
||||||
|
Next: <strong title={formatNextSyncTime(scheduled.nextSyncAt)}>
|
||||||
|
{getNextSyncLabel(scheduled.nextSyncAt, scheduled.interval)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span class="job-time">
|
||||||
|
Last: {formatLastSyncTime(scheduled.lastSyncAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scheduled-countdown">
|
||||||
|
<div class="countdown-bar">
|
||||||
|
<div class="countdown-progress"></div>
|
||||||
|
</div>
|
||||||
|
<p class="countdown-text">
|
||||||
|
{formatNextSyncTime(scheduled.nextSyncAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Recent Sync Jobs -->
|
<!-- Recent Sync Jobs -->
|
||||||
<div class="jobs-section">
|
<div class="jobs-section">
|
||||||
<h2 class="section-title">🔄 Recent Sync Jobs</h2>
|
<h2 class="section-title">🔄 Recent Sync Jobs</h2>
|
||||||
@@ -316,13 +445,13 @@
|
|||||||
|
|
||||||
.welcome h1 {
|
.welcome h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,12 +464,12 @@
|
|||||||
|
|
||||||
.dashboard h1 {
|
.dashboard h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-section p {
|
.welcome-section p {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@@ -353,21 +482,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-card {
|
.action-card {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card h3 {
|
.action-card h3 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card p {
|
.action-card p {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +504,7 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -384,25 +513,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background-color: #357abd;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card .btn {
|
.action-card .btn {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -410,25 +539,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-card .btn:hover {
|
.action-card .btn:hover {
|
||||||
background-color: #357abd;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #f8f9fa;
|
background: var(--bg-secondary);
|
||||||
border-left: 4px solid #4a90e2;
|
border-left: 4px solid var(--color-primary);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box h3 {
|
.info-box h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box ol {
|
.info-box ol {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,18 +568,18 @@
|
|||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-actions {
|
.empty-actions {
|
||||||
@@ -468,20 +597,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-card {
|
.job-card {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-card:hover {
|
.job-card:hover {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-card.failed {
|
.job-card.failed {
|
||||||
border-left: 4px solid #dc3545;
|
border-left: 4px solid var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card-scheduled {
|
||||||
|
border-left: 4px solid var(--badge-purple-bg);
|
||||||
|
background: var(--gradient-scheduled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-header {
|
.job-header {
|
||||||
@@ -503,69 +637,83 @@
|
|||||||
|
|
||||||
.job-name {
|
.job-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-id {
|
.job-id {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #999;
|
color: var(--text-muted);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-pill);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-yellow-100 {
|
.bg-yellow-100 {
|
||||||
background-color: #fef3c7;
|
background-color: var(--badge-pending-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-blue-100 {
|
.bg-blue-100 {
|
||||||
background-color: #dbeafe;
|
background-color: var(--badge-running-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-green-100 {
|
.bg-green-100 {
|
||||||
background-color: #d1fae5;
|
background-color: var(--badge-completed-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-red-100 {
|
.bg-red-100 {
|
||||||
background-color: #fee2e2;
|
background-color: var(--badge-failed-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-yellow-800 {
|
.text-yellow-800 {
|
||||||
color: #92400e;
|
color: var(--badge-pending-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-blue-800 {
|
.text-blue-800 {
|
||||||
color: #1e40af;
|
color: var(--badge-running-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-green-800 {
|
.text-green-800 {
|
||||||
color: #065f46;
|
color: var(--badge-completed-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-red-800 {
|
.text-red-800 {
|
||||||
color: #991b1b;
|
color: var(--badge-failed-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-purple-100 {
|
.bg-purple-100 {
|
||||||
background-color: #f3e8ff;
|
background-color: var(--badge-cancelled-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-purple-800 {
|
.text-purple-800 {
|
||||||
color: #6b21a8;
|
color: var(--badge-cancelled-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-badge {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduled-badge {
|
||||||
|
background-color: var(--badge-purple-bg);
|
||||||
|
color: var(--badge-purple-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-meta {
|
.job-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -576,14 +724,14 @@
|
|||||||
|
|
||||||
.job-time,
|
.job-time,
|
||||||
.job-duration {
|
.job-duration {
|
||||||
color: #999;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-error {
|
.job-error {
|
||||||
background: #fee2e2;
|
background: var(--color-error-bg);
|
||||||
color: #991b1b;
|
color: var(--color-error-text);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -597,29 +745,29 @@
|
|||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
background: #f8f9fa;
|
background: var(--bg-secondary);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item strong {
|
.stat-item strong {
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-added {
|
.stat-added {
|
||||||
color: #065f46;
|
color: var(--color-success);
|
||||||
background: #d1fae5;
|
background: var(--color-success-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-updated {
|
.stat-updated {
|
||||||
color: #1e40af;
|
color: var(--color-info);
|
||||||
background: #dbeafe;
|
background: var(--color-info-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-skipped {
|
.stat-skipped {
|
||||||
color: #92400e;
|
color: var(--badge-pending-text);
|
||||||
background: #fef3c7;
|
background: var(--badge-pending-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-progress {
|
.job-progress {
|
||||||
@@ -627,7 +775,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
color: #1e40af;
|
color: var(--color-info);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -641,17 +789,17 @@
|
|||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
border: 1px solid #dc3545;
|
border: 1px solid var(--color-error);
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
color: #dc3545;
|
color: var(--color-error);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel:hover:not(:disabled) {
|
.btn-cancel:hover:not(:disabled) {
|
||||||
background: #dc3545;
|
background: var(--color-error);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,4 +807,53 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scheduled Jobs Section */
|
||||||
|
.scheduled-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scheduled job countdown */
|
||||||
|
.scheduled-countdown {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--border-color-light);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-progress {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--gradient-purple);
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 100%;
|
||||||
|
animation: pulse-countdown 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-countdown {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--badge-purple-bg);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.scheduled-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { auth } from '$lib/stores.js';
|
import { auth } from '$lib/stores.js';
|
||||||
import { adminAPI } from '$lib/api.js';
|
import { adminAPI, authAPI } from '$lib/api.js';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
let impersonationStatus = null;
|
let impersonationStatus = null;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let selectedTab = 'overview'; // 'overview', 'users', 'actions'
|
let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
|
||||||
let showImpersonationModal = false;
|
let showImpersonationModal = false;
|
||||||
let showDeleteUserModal = false;
|
let showDeleteUserModal = false;
|
||||||
let showRoleChangeModal = false;
|
let showRoleChangeModal = false;
|
||||||
@@ -90,16 +90,16 @@
|
|||||||
const data = await adminAPI.impersonate(userId);
|
const data = await adminAPI.impersonate(userId);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Store new token
|
// Store the new impersonation token
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update auth store with new user data
|
// Fetch the full user profile (which includes impersonatedBy)
|
||||||
auth.login({
|
const profileData = await authAPI.getProfile();
|
||||||
...data.impersonating,
|
|
||||||
impersonatedBy: $auth.user.id,
|
// Update auth store with complete user data
|
||||||
});
|
auth.loginWithToken(profileData.user, data.token);
|
||||||
|
|
||||||
// Redirect to home page
|
// Redirect to home page
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@@ -114,32 +114,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStopImpersonation() {
|
|
||||||
try {
|
|
||||||
loading = true;
|
|
||||||
const data = await adminAPI.stopImpersonation();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
// Store admin token
|
|
||||||
if (browser) {
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update auth store
|
|
||||||
auth.login(data.user);
|
|
||||||
|
|
||||||
alert(data.message);
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Failed to stop impersonation: ' + (data.error || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to stop impersonation: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteUser(userId) {
|
async function handleDeleteUser(userId) {
|
||||||
const user = users.find(u => u.id === userId);
|
const user = users.find(u => u.id === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -203,7 +177,11 @@
|
|||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
// Handle Unix timestamps (seconds) by converting to milliseconds
|
||||||
|
const date = typeof dateString === 'number'
|
||||||
|
? new Date(dateString * 1000)
|
||||||
|
: new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -232,21 +210,6 @@
|
|||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="admin-dashboard">
|
<div class="admin-dashboard">
|
||||||
<!-- 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}>
|
|
||||||
Stop Impersonation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<h1>Admin Dashboard</h1>
|
<h1>Admin Dashboard</h1>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
@@ -263,6 +226,12 @@
|
|||||||
>
|
>
|
||||||
Users
|
Users
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {selectedTab === 'awards' ? 'active' : ''}"
|
||||||
|
on:click={() => selectedTab = 'awards'}
|
||||||
|
>
|
||||||
|
Awards
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
||||||
on:click={() => selectedTab = 'actions'}
|
on:click={() => selectedTab = 'actions'}
|
||||||
@@ -367,6 +336,7 @@
|
|||||||
<th>DCL Conf.</th>
|
<th>DCL Conf.</th>
|
||||||
<th>Total Conf.</th>
|
<th>Total Conf.</th>
|
||||||
<th>Last Sync</th>
|
<th>Last Sync</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -386,6 +356,7 @@
|
|||||||
<td>{user.dclConfirmed || 0}</td>
|
<td>{user.dclConfirmed || 0}</td>
|
||||||
<td>{user.totalConfirmed || 0}</td>
|
<td>{user.totalConfirmed || 0}</td>
|
||||||
<td>{formatDate(user.lastSync)}</td>
|
<td>{formatDate(user.lastSync)}</td>
|
||||||
|
<td>{formatDate(user.lastSeen)}</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<button
|
<button
|
||||||
class="action-button impersonate-btn"
|
class="action-button impersonate-btn"
|
||||||
@@ -419,6 +390,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Awards Tab -->
|
||||||
|
{#if selectedTab === 'awards'}
|
||||||
|
<div class="tab-content">
|
||||||
|
<h2>Award Definitions</h2>
|
||||||
|
<p class="help-text">Manage award definitions. Create, edit, and delete awards.</p>
|
||||||
|
|
||||||
|
<div class="awards-quick-actions">
|
||||||
|
<a href="/admin/awards" class="btn btn-primary">Manage Awards</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="awards-info">
|
||||||
|
<h3>Award Management</h3>
|
||||||
|
<p>From the Awards management page, you can:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Create</strong> new award definitions</li>
|
||||||
|
<li><strong>Edit</strong> existing award definitions</li>
|
||||||
|
<li><strong>Delete</strong> awards</li>
|
||||||
|
<li><strong>Test</strong> award calculations with sample user data</li>
|
||||||
|
</ul>
|
||||||
|
<p>All award definitions are stored as JSON files in the <code>award-definitions/</code> directory.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Actions Tab -->
|
<!-- Actions Tab -->
|
||||||
{#if selectedTab === 'actions'}
|
{#if selectedTab === 'actions'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -563,58 +558,20 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
background-color: #fee;
|
background-color: var(--color-error-bg);
|
||||||
border: 1px solid #fcc;
|
border: 1px solid var(--color-error);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
color: #c00;
|
color: var(--color-error-text);
|
||||||
}
|
|
||||||
|
|
||||||
/* Impersonation Banner */
|
|
||||||
.impersonation-banner {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border: 2px solid #ffc107;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.impersonation-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.impersonation-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-impersonation-btn {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: #000;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-impersonation-btn:hover {
|
|
||||||
background-color: #e0a800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
@@ -622,7 +579,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 2px solid #ddd;
|
border-bottom: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@@ -632,30 +589,30 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
border-bottom: 2px solid #007bff;
|
border-bottom: 2px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats Grid */
|
/* Stats Grid */
|
||||||
@@ -670,8 +627,8 @@
|
|||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.stat-card h3 {
|
||||||
@@ -714,9 +671,11 @@
|
|||||||
.search-input,
|
.search-input,
|
||||||
.filter-select {
|
.filter-select {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
@@ -731,8 +690,8 @@
|
|||||||
.users-table {
|
.users-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,23 +699,23 @@
|
|||||||
.users-table td {
|
.users-table td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-table th {
|
.users-table th {
|
||||||
background-color: #f5f5f5;
|
background-color: var(--bg-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-table tr:hover {
|
.users-table tr:hover {
|
||||||
background-color: #f9f9f9;
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row {
|
.admin-row {
|
||||||
background-color: #fff9e6 !important;
|
background-color: var(--color-warning-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-cell {
|
.actions-cell {
|
||||||
@@ -767,7 +726,7 @@
|
|||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -788,21 +747,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-btn {
|
.role-btn {
|
||||||
background-color: #ffc107;
|
background-color: var(--color-warning);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-btn:hover:not(:disabled) {
|
.role-btn:hover:not(:disabled) {
|
||||||
background-color: #e0a800;
|
background-color: var(--color-warning-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
background-color: #dc3545;
|
background-color: var(--color-error);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover:not(:disabled) {
|
.delete-btn:hover:not(:disabled) {
|
||||||
background-color: #c82333;
|
background-color: var(--color-error-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
@@ -818,13 +777,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-badge.user {
|
.role-badge.user {
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-count {
|
.users-count {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,8 +795,8 @@
|
|||||||
.actions-table {
|
.actions-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,15 +804,15 @@
|
|||||||
.actions-table td {
|
.actions-table td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-table th {
|
.actions-table th {
|
||||||
background-color: #f5f5f5;
|
background-color: var(--bg-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-type {
|
.action-type {
|
||||||
@@ -863,13 +822,13 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-type.impostor_start {
|
.action-type.impersonate_start {
|
||||||
background-color: #ffc107;
|
background-color: var(--color-warning);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-type.impostor_stop {
|
.action-type.impersonate_stop {
|
||||||
background-color: #28a745;
|
background-color: var(--color-success-light);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,14 +838,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-type.user_delete {
|
.action-type.user_delete {
|
||||||
background-color: #dc3545;
|
background-color: var(--color-error);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-json {
|
.details-json {
|
||||||
background-color: #f5f5f5;
|
background-color: var(--bg-secondary);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -895,7 +854,7 @@
|
|||||||
.no-actions {
|
.no-actions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
@@ -913,30 +872,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h2 {
|
.modal-content h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content p {
|
.modal-content p {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content .warning {
|
.modal-content .warning {
|
||||||
background-color: #fff3cd;
|
background-color: var(--badge-pending-bg);
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid var(--color-warning);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: #856404;
|
color: var(--badge-pending-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
@@ -949,36 +908,36 @@
|
|||||||
.modal-button {
|
.modal-button {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button.cancel {
|
.modal-button.cancel {
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button.cancel:hover {
|
.modal-button.cancel:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button.confirm {
|
.modal-button.confirm {
|
||||||
background-color: #007bff;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button.confirm:hover {
|
.modal-button.confirm:hover {
|
||||||
background-color: #0056b3;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button.delete-confirm {
|
.modal-button.delete-confirm {
|
||||||
background-color: #dc3545;
|
background-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-button.delete-confirm:hover {
|
.modal-button.delete-confirm:hover {
|
||||||
background-color: #c82333;
|
background-color: var(--color-error-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-options {
|
.role-options {
|
||||||
@@ -995,6 +954,50 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-quick-actions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info ul {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info code {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.users-header {
|
.users-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
380
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
380
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
<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'
|
||||||
|
};
|
||||||
|
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: 1400px;
|
||||||
|
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>
|
||||||
1402
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
1402
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,841 @@
|
|||||||
|
<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 && rules.displayField) {
|
||||||
|
const defaults = {
|
||||||
|
'dxcc': 'entity',
|
||||||
|
'state': 'state',
|
||||||
|
'grid': 'grid',
|
||||||
|
'callsign': 'callsign'
|
||||||
|
};
|
||||||
|
if (defaults[rules.entityType] === rules.displayField) {
|
||||||
|
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
1428
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
1428
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -180,13 +180,13 @@
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,28 +205,29 @@
|
|||||||
|
|
||||||
.filter-group label {
|
.filter-group label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group select {
|
.filter-group select {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
background-color: white;
|
background-color: var(--bg-input);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group select:hover {
|
.filter-group select:hover {
|
||||||
border-color: #4a90e2;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group select:focus {
|
.filter-group select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4a90e2;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
@@ -235,11 +236,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #d32f2f;
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.awards-grid {
|
.awards-grid {
|
||||||
@@ -249,11 +250,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.award-card {
|
.award-card {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -268,16 +269,16 @@
|
|||||||
|
|
||||||
.award-header h2 {
|
.award-header h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category {
|
.category {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-pill);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -285,7 +286,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
@@ -295,8 +296,8 @@
|
|||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--bg-secondary);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
@@ -307,14 +308,14 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,15 +326,15 @@
|
|||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background-color: #e0e0e0;
|
background-color: var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4a90e2 0%, #357abd 100%);
|
background: var(--gradient-primary);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +349,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.worked,
|
.worked,
|
||||||
@@ -357,20 +358,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.worked {
|
.worked {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmed {
|
.confirmed {
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
@@ -379,6 +380,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background-color: #357abd;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -899,22 +899,22 @@
|
|||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.back-button:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-buttons {
|
.header-buttons {
|
||||||
@@ -924,16 +924,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
background: #f8f9fa;
|
background: var(--bg-secondary);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters h3 {
|
.filters h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-header {
|
.filters-header {
|
||||||
@@ -947,11 +947,11 @@
|
|||||||
|
|
||||||
.filter-count {
|
.filter-count {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-count strong {
|
.filter-count strong {
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,24 +964,28 @@
|
|||||||
|
|
||||||
.filter-row select {
|
.filter-row select {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4a90e2;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.checkbox-label {
|
||||||
@@ -994,7 +998,7 @@
|
|||||||
.btn {
|
.btn {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1002,12 +1006,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background-color: #357abd;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
@@ -1016,21 +1020,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background-color: #6c757d;
|
background-color: var(--color-secondary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background-color: #dc3545;
|
background-color: var(--color-error);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background-color: #c82333;
|
background-color: var(--color-error-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:disabled {
|
.btn-danger:disabled {
|
||||||
@@ -1043,14 +1047,14 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1058,21 +1062,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alert-success {
|
.alert-success {
|
||||||
background-color: #d4edda;
|
background-color: var(--color-success-bg);
|
||||||
border: 1px solid #c3e6cb;
|
border: 1px solid var(--color-success);
|
||||||
color: #155724;
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-error {
|
.alert-error {
|
||||||
background-color: #f8d7da;
|
background-color: var(--color-error-bg);
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid var(--color-error);
|
||||||
color: #721c24;
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-info {
|
.alert-info {
|
||||||
background-color: #d1ecf1;
|
background-color: var(--color-info-bg);
|
||||||
border: 1px solid #bee5eb;
|
border: 1px solid var(--color-info);
|
||||||
color: #0c5460;
|
color: var(--color-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert h3 {
|
.alert h3 {
|
||||||
@@ -1090,11 +1094,13 @@
|
|||||||
|
|
||||||
.delete-input {
|
.delete-input {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-buttons {
|
.delete-buttons {
|
||||||
@@ -1105,8 +1111,8 @@
|
|||||||
|
|
||||||
.qso-table-container {
|
.qso-table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qso-table {
|
.qso-table {
|
||||||
@@ -1118,39 +1124,39 @@
|
|||||||
.qso-table td {
|
.qso-table td {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qso-table th {
|
.qso-table th {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--bg-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qso-table tr:hover {
|
.qso-table tr:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.callsign {
|
.callsign {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background-color: #d4edda;
|
background-color: var(--color-success-bg);
|
||||||
color: #155724;
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-pending {
|
.badge-pending {
|
||||||
background-color: #fff3cd;
|
background-color: var(--badge-pending-bg);
|
||||||
color: #856404;
|
color: var(--badge-pending-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmation-list {
|
.confirmation-list {
|
||||||
@@ -1168,29 +1174,29 @@
|
|||||||
.service-type {
|
.service-type {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmation-date {
|
.confirmation-date {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .error, .empty {
|
.loading, .error, .empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #dc3545;
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.showing {
|
.showing {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -1201,14 +1207,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #f8f9fa;
|
background: var(--bg-secondary);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-info {
|
.pagination-info {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,7 +1232,7 @@
|
|||||||
|
|
||||||
.page-ellipsis {
|
.page-ellipsis {
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-small {
|
.btn-small {
|
||||||
@@ -1241,16 +1247,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-log {
|
.import-log {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-log h3 {
|
.import-log h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,15 +1270,15 @@
|
|||||||
|
|
||||||
.log-section h4 {
|
.log-section h4 {
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
color: #555;
|
color: var(--text-secondary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-table-container {
|
.log-table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-table {
|
.log-table {
|
||||||
@@ -1285,13 +1291,13 @@
|
|||||||
.log-table td {
|
.log-table td {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-table th {
|
.log-table th {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--bg-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,12 +1306,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-table tr:hover {
|
.log-table tr:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-table .callsign {
|
.log-table .callsign {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* QSO Detail Modal Styles */
|
/* QSO Detail Modal Styles */
|
||||||
@@ -1315,11 +1321,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qso-row:hover {
|
.qso-row:hover {
|
||||||
background-color: #f0f7ff !important;
|
background-color: var(--color-primary-light) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qso-row:focus {
|
.qso-row:focus {
|
||||||
outline: 2px solid #4a90e2;
|
outline: 2px solid var(--color-primary);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1340,13 +1346,13 @@
|
|||||||
|
|
||||||
/* Modal Content */
|
/* Modal Content */
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal Header */
|
/* Modal Header */
|
||||||
@@ -1355,13 +1361,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
@@ -1376,14 +1382,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
background-color: #f0f0f0;
|
background-color: var(--bg-tertiary);
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal Body */
|
/* Modal Body */
|
||||||
@@ -1402,10 +1408,10 @@
|
|||||||
|
|
||||||
.qso-detail-section h3 {
|
.qso-detail-section h3 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 2px solid #e0e0e0;
|
border-bottom: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail Grid */
|
/* Detail Grid */
|
||||||
@@ -1423,14 +1429,14 @@
|
|||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,10 +1449,10 @@
|
|||||||
|
|
||||||
.confirmation-service h4 {
|
.confirmation-service h4 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid #e0e0e0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmation-status-item {
|
.confirmation-status-item {
|
||||||
@@ -1466,19 +1472,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.confirmed {
|
.status-badge.confirmed {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.not-confirmed,
|
.status-badge.not-confirmed,
|
||||||
.status-badge.no-data {
|
.status-badge.no-data {
|
||||||
background-color: #e0e0e0;
|
background-color: var(--border-color);
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.unknown {
|
.status-badge.unknown {
|
||||||
background-color: #fff3cd;
|
background-color: var(--badge-pending-bg);
|
||||||
color: #856404;
|
color: var(--badge-pending-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meta Info */
|
/* Meta Info */
|
||||||
@@ -1486,18 +1492,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--bg-secondary);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-label {
|
.meta-label {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-value {
|
.meta-value {
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,15 +1513,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content::-webkit-scrollbar-track {
|
.modal-content::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content::-webkit-scrollbar-thumb {
|
.modal-content::-webkit-scrollbar-thumb {
|
||||||
background: #888;
|
background: var(--text-muted);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: var(--bg-card);
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #4a90e2;
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.lotw-btn {
|
.lotw-btn {
|
||||||
background-color: #4a90e2;
|
background-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lotw-btn:hover:not(:disabled) {
|
.lotw-btn:hover:not(:disabled) {
|
||||||
background-color: #357abd;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dcl-btn {
|
.dcl-btn {
|
||||||
|
|||||||
@@ -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,9 +17,23 @@
|
|||||||
let hasLoTWCredentials = false;
|
let hasLoTWCredentials = false;
|
||||||
let hasDCLCredentials = false;
|
let hasDCLCredentials = false;
|
||||||
|
|
||||||
|
// Auto-sync settings
|
||||||
|
let autoSyncSettings = {
|
||||||
|
lotwEnabled: false,
|
||||||
|
lotwIntervalHours: 24,
|
||||||
|
lotwNextSyncAt: null,
|
||||||
|
dclEnabled: false,
|
||||||
|
dclIntervalHours: 24,
|
||||||
|
dclNextSyncAt: null,
|
||||||
|
};
|
||||||
|
let loadingAutoSync = false;
|
||||||
|
let savingAutoSync = false;
|
||||||
|
let successAutoSync = false;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Load user profile to check if credentials exist
|
// Load user profile to check if credentials exist
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
|
await loadAutoSyncSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
@@ -40,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();
|
||||||
|
|
||||||
@@ -91,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>
|
||||||
|
|
||||||
@@ -237,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>
|
||||||
@@ -438,4 +615,58 @@
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 #e0e0e0;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-sync-info {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 4px solid #4a90e2;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user