Compare commits
10 Commits
ed433902d9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b296514356
|
|||
|
70858836d0
|
|||
|
257ebf6c5d
|
|||
|
caf7703073
|
|||
|
fa6420d149
|
|||
|
aa55158347
|
|||
|
|
e4e7f3c208 | ||
|
a35731f626
|
|||
|
2ae47232cb
|
|||
|
8b846bffbe
|
73
CLAUDE.md
73
CLAUDE.md
@@ -553,3 +553,76 @@ const params = new URLSearchParams({
|
||||
**QSO Management**:
|
||||
- Fixed DELETE /api/qsos/all to handle foreign key constraints
|
||||
- Added cache invalidation after QSO deletion
|
||||
|
||||
### Admin System and User Roles
|
||||
|
||||
The application supports three user roles with different permission levels:
|
||||
|
||||
**User Roles**:
|
||||
- **Regular User**: View own QSOs, sync from LoTW/DCL, track award progress
|
||||
- **Admin**: All user permissions + view system stats + manage users + impersonate regular users
|
||||
- **Super Admin**: All admin permissions + promote/demote admins + impersonate admins
|
||||
|
||||
**Database Schema** (`src/backend/db/schema/index.js`):
|
||||
- `isAdmin`: Boolean flag for admin users (default: false)
|
||||
- `isSuperAdmin`: Boolean flag for super-admin users (default: false)
|
||||
|
||||
**Admin Service** (`src/backend/services/admin.service.js`):
|
||||
- `isAdmin(userId)`: Check if user is admin
|
||||
- `isSuperAdmin(userId)`: Check if user is super-admin
|
||||
- `changeUserRole(adminId, targetUserId, newRole)`: Change user role ('user', 'admin', 'super-admin')
|
||||
- `impersonateUser(adminId, targetUserId)`: Start impersonating a user
|
||||
- `verifyImpersonation(token)`: Verify impersonation token validity
|
||||
- `stopImpersonation(adminId, targetUserId)`: Stop impersonation
|
||||
- `logAdminAction(adminId, actionType, targetUserId, details)`: Log admin actions
|
||||
|
||||
**Security Rules**:
|
||||
1. Only super-admins can promote/demote super-admins
|
||||
2. Regular admins cannot promote users to super-admin
|
||||
3. Super-admins cannot demote themselves (prevents lockout)
|
||||
4. Cannot demote the last super-admin
|
||||
5. Regular admins can only impersonate regular users
|
||||
6. Super-admins can impersonate any user (including other admins)
|
||||
|
||||
**Backend API Routes** (`src/backend/index.js`):
|
||||
- `POST /api/admin/users/:userId/role`: Change user role
|
||||
- Body: `{ "role": "user" | "admin" | "super-admin" }`
|
||||
- `POST /api/admin/impersonate/:userId`: Start impersonating
|
||||
- `POST /api/admin/impersonate/stop`: Stop impersonating
|
||||
- `GET /api/admin/impersonation/status`: Check impersonation status
|
||||
- `GET /api/admin/stats`: System statistics
|
||||
- `GET /api/admin/users`: List all users
|
||||
- `GET /api/admin/actions`: Admin action log
|
||||
- `DELETE /api/admin/users/:userId`: Delete user
|
||||
|
||||
**JWT Token Claims**:
|
||||
```javascript
|
||||
{
|
||||
userId: number,
|
||||
email: string,
|
||||
callsign: string,
|
||||
isAdmin: boolean,
|
||||
isSuperAdmin: boolean, // Super-admin flag
|
||||
impersonatedBy: number, // Present when impersonating
|
||||
exp: number
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend Admin Page** (`src/frontend/src/routes/admin/+page.svelte`):
|
||||
- System statistics dashboard
|
||||
- User management with filtering (all, super-admin, admin, user)
|
||||
- Role change modal (user → admin → super-admin)
|
||||
- Impersonate button (enabled for super-admins targeting admins)
|
||||
- Admin action log viewing
|
||||
|
||||
**To create the first super-admin**:
|
||||
1. Register a user account
|
||||
2. Access database: `sqlite3 src/backend/award.db`
|
||||
3. Run: `UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';`
|
||||
4. Log out and log back in to get updated JWT token
|
||||
|
||||
**To promote via admin interface**:
|
||||
1. Log in as existing super-admin
|
||||
2. Navigate to `/admin`
|
||||
3. Find user in Users tab
|
||||
4. Click "Promote" and select "Super Admin"
|
||||
|
||||
49
README.md
49
README.md
@@ -277,6 +277,52 @@ The application will be available at:
|
||||
### Health
|
||||
- `GET /api/health` - Health check endpoint
|
||||
|
||||
### Admin API (Admin Only)
|
||||
|
||||
All admin endpoints require authentication and admin privileges.
|
||||
|
||||
- `GET /api/admin/stats` - Get system-wide statistics
|
||||
- `GET /api/admin/users` - Get all users with statistics
|
||||
- `GET /api/admin/users/:userId` - Get detailed information about a specific user
|
||||
- `POST /api/admin/users/:userId/role` - Update user role (`user`, `admin`, `super-admin`)
|
||||
- `DELETE /api/admin/users/:userId` - Delete a user
|
||||
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
|
||||
- `POST /api/admin/impersonate/stop` - Stop impersonating and return to admin account
|
||||
- `GET /api/admin/impersonation/status` - Get current impersonation status
|
||||
- `GET /api/admin/actions` - Get admin actions log
|
||||
- `GET /api/admin/actions/my` - Get current admin's action log
|
||||
|
||||
### User Roles and Permissions
|
||||
|
||||
The application supports three user roles with different permission levels:
|
||||
|
||||
**Regular User**
|
||||
- View own QSOs
|
||||
- Sync from LoTW and DCL
|
||||
- Track award progress
|
||||
- Manage own credentials
|
||||
|
||||
**Admin**
|
||||
- All user permissions
|
||||
- View system statistics
|
||||
- View all users
|
||||
- Promote/demote regular users to/from admin
|
||||
- Delete regular users
|
||||
- Impersonate regular users (for support)
|
||||
- View admin action log
|
||||
|
||||
**Super Admin**
|
||||
- All admin permissions
|
||||
- Promote/demote admins to/from super-admin
|
||||
- Impersonate other admins (for support)
|
||||
- Full access to all admin functions
|
||||
|
||||
**Security Rules:**
|
||||
- Only super-admins can promote or demote super-admins
|
||||
- Regular admins cannot promote users to super-admin
|
||||
- Super-admins cannot demote themselves
|
||||
- Cannot demote the last super-admin
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Users Table
|
||||
@@ -289,6 +335,9 @@ CREATE TABLE users (
|
||||
lotwUsername TEXT,
|
||||
lotwPassword TEXT,
|
||||
dclApiKey TEXT, -- DCL API key (for future use)
|
||||
isAdmin INTEGER DEFAULT 0 NOT NULL,
|
||||
isSuperAdmin INTEGER DEFAULT 0 NOT NULL,
|
||||
lastSeen INTEGER,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
115
award-data/wae-country-list.json
Normal file
115
award-data/wae-country-list.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"dxccBased": [
|
||||
{ "entityId": 230, "country": "Germany", "prefix": "DL", "deleted": false },
|
||||
{ "entityId": 227, "country": "France", "prefix": "F", "deleted": false },
|
||||
{ "entityId": 248, "country": "Italy", "prefix": "I", "deleted": false },
|
||||
{ "entityId": 223, "country": "England", "prefix": "G", "deleted": false },
|
||||
{ "entityId": 279, "country": "Scotland", "prefix": "GM", "deleted": false },
|
||||
{ "entityId": 265, "country": "Northern Ireland", "prefix": "GI", "deleted": false },
|
||||
{ "entityId": 294, "country": "Wales", "prefix": "GW", "deleted": false },
|
||||
{ "entityId": 114, "country": "Isle of Man", "prefix": "GD", "deleted": false },
|
||||
{ "entityId": 122, "country": "Jersey", "prefix": "GJ", "deleted": false },
|
||||
{ "entityId": 106, "country": "Guernsey", "prefix": "GU", "deleted": false },
|
||||
{ "entityId": 236, "country": "Greece", "prefix": "SV", "deleted": false },
|
||||
{ "entityId": 209, "country": "Belgium", "prefix": "ON", "deleted": false },
|
||||
{ "entityId": 263, "country": "Netherlands", "prefix": "PA", "deleted": false },
|
||||
{ "entityId": 287, "country": "Switzerland", "prefix": "HB", "deleted": false },
|
||||
{ "entityId": 281, "country": "Spain", "prefix": "EA", "deleted": false },
|
||||
{ "entityId": 272, "country": "Portugal", "prefix": "CT", "deleted": false },
|
||||
{ "entityId": 206, "country": "Austria", "prefix": "OE", "deleted": false },
|
||||
{ "entityId": 503, "country": "Czech Republic", "prefix": "OK", "deleted": false },
|
||||
{ "entityId": 504, "country": "Slovakia", "prefix": "OM", "deleted": false },
|
||||
{ "entityId": 239, "country": "Hungary", "prefix": "HA", "deleted": false },
|
||||
{ "entityId": 269, "country": "Poland", "prefix": "SP", "deleted": false },
|
||||
{ "entityId": 284, "country": "Sweden", "prefix": "SM", "deleted": false },
|
||||
{ "entityId": 266, "country": "Norway", "prefix": "LA", "deleted": false },
|
||||
{ "entityId": 221, "country": "Denmark", "prefix": "OZ", "deleted": false },
|
||||
{ "entityId": 224, "country": "Finland", "prefix": "OH", "deleted": false },
|
||||
{ "entityId": 52, "country": "Estonia", "prefix": "ES", "deleted": false },
|
||||
{ "entityId": 145, "country": "Latvia", "prefix": "YL", "deleted": false },
|
||||
{ "entityId": 146, "country": "Lithuania", "prefix": "LY", "deleted": false },
|
||||
{ "entityId": 27, "country": "Belarus", "prefix": "EU", "deleted": false },
|
||||
{ "entityId": 288, "country": "Ukraine", "prefix": "UR", "deleted": false },
|
||||
{ "entityId": 179, "country": "Moldova", "prefix": "ER", "deleted": false },
|
||||
{ "entityId": 275, "country": "Romania", "prefix": "YO", "deleted": false },
|
||||
{ "entityId": 212, "country": "Bulgaria", "prefix": "LZ", "deleted": false },
|
||||
{ "entityId": 296, "country": "Serbia", "prefix": "YT", "deleted": false },
|
||||
{ "entityId": 497, "country": "Croatia", "prefix": "9A", "deleted": false },
|
||||
{ "entityId": 499, "country": "Slovenia", "prefix": "S5", "deleted": false },
|
||||
{ "entityId": 501, "country": "Bosnia and Herzegovina", "prefix": "E7", "deleted": false },
|
||||
{ "entityId": 502, "country": "North Macedonia", "prefix": "Z3", "deleted": false },
|
||||
{ "entityId": 7, "country": "Albania", "prefix": "ZA", "deleted": false },
|
||||
{ "entityId": 514, "country": "Montenegro", "prefix": "4O", "deleted": false },
|
||||
{ "entityId": 54, "country": "Russia (European)", "prefix": "UA", "deleted": false },
|
||||
{ "entityId": 126, "country": "Kaliningrad", "prefix": "UA2", "deleted": false },
|
||||
{ "entityId": 390, "country": "Turkey", "prefix": "TA", "deleted": false },
|
||||
{ "entityId": 215, "country": "Cyprus", "prefix": "5B", "deleted": false },
|
||||
{ "entityId": 257, "country": "Malta", "prefix": "9H", "deleted": false },
|
||||
{ "entityId": 242, "country": "Iceland", "prefix": "TF", "deleted": false },
|
||||
{ "entityId": 245, "country": "Ireland", "prefix": "EI", "deleted": false },
|
||||
{ "entityId": 254, "country": "Luxembourg", "prefix": "LX", "deleted": false },
|
||||
{ "entityId": 260, "country": "Monaco", "prefix": "3A", "deleted": false },
|
||||
{ "entityId": 203, "country": "Andorra", "prefix": "C3", "deleted": false },
|
||||
{ "entityId": 278, "country": "San Marino", "prefix": "T7", "deleted": false },
|
||||
{ "entityId": 295, "country": "Vatican City", "prefix": "HV", "deleted": false },
|
||||
{ "entityId": 251, "country": "Liechtenstein", "prefix": "HB0", "deleted": false }
|
||||
],
|
||||
"waeSpecific": [
|
||||
{
|
||||
"country": "Shetland Islands",
|
||||
"prefix": "GM/S",
|
||||
"callsigns": ["GM/S*", "GS/S*", "2M/S*"],
|
||||
"parentDxcc": 279
|
||||
},
|
||||
{
|
||||
"country": "European Turkey",
|
||||
"prefix": "TA1",
|
||||
"callsigns": ["TA1*"],
|
||||
"parentDxcc": 390
|
||||
},
|
||||
{
|
||||
"country": "Sardinia",
|
||||
"prefix": "IS0",
|
||||
"callsigns": ["IS0*"],
|
||||
"parentDxcc": 248
|
||||
},
|
||||
{
|
||||
"country": "Sicily",
|
||||
"prefix": "IT9",
|
||||
"callsigns": ["IT9*"],
|
||||
"parentDxcc": 248
|
||||
},
|
||||
{
|
||||
"country": "Corsica",
|
||||
"prefix": "TK",
|
||||
"callsigns": ["TK*"],
|
||||
"parentDxcc": 227
|
||||
},
|
||||
{
|
||||
"country": "Crete",
|
||||
"prefix": "SV9",
|
||||
"callsigns": ["SV9*", "J49*"],
|
||||
"parentDxcc": 236
|
||||
},
|
||||
{
|
||||
"country": "ITU Headquarters Geneva",
|
||||
"prefix": "4U1I",
|
||||
"callsigns": ["4U1I"],
|
||||
"parentDxcc": null
|
||||
},
|
||||
{
|
||||
"country": "UN Vienna",
|
||||
"prefix": "4U1V",
|
||||
"callsigns": ["4U1V"],
|
||||
"parentDxcc": null
|
||||
}
|
||||
],
|
||||
"deletedCountries": [
|
||||
{
|
||||
"country": "German Democratic Republic",
|
||||
"prefix": "Y2",
|
||||
"deleted": "1990-10-03",
|
||||
"formerEntityId": 229
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36,5 +36,27 @@
|
||||
"FM",
|
||||
"SSB"
|
||||
]
|
||||
}
|
||||
},
|
||||
"achievements": [
|
||||
{
|
||||
"name": "DLD50",
|
||||
"threshold": 50
|
||||
},
|
||||
{
|
||||
"name": "DLD100",
|
||||
"threshold": 100
|
||||
},
|
||||
{
|
||||
"name": "DLD200",
|
||||
"threshold": 200
|
||||
},
|
||||
{
|
||||
"name": "DLD500",
|
||||
"threshold": 500
|
||||
},
|
||||
{
|
||||
"name": "DLD1000",
|
||||
"threshold": 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,14 +9,57 @@
|
||||
"target": 50,
|
||||
"countMode": "perBandMode",
|
||||
"stations": [
|
||||
{ "callsign": "DF2ET", "points": 10 },
|
||||
{ "callsign": "DJ7NT", "points": 10 },
|
||||
{ "callsign": "HB9HIL", "points": 10 },
|
||||
{ "callsign": "LA8AJA", "points": 10 },
|
||||
{ "callsign": "DB4SCW", "points": 5 },
|
||||
{ "callsign": "DG2RON", "points": 5 },
|
||||
{ "callsign": "DG0TM", "points": 5 },
|
||||
{ "callsign": "DO8MKR", "points": 5 }
|
||||
{
|
||||
"callsign": "DF2ET",
|
||||
"points": 10
|
||||
},
|
||||
{
|
||||
"callsign": "DJ7NT",
|
||||
"points": 10
|
||||
},
|
||||
{
|
||||
"callsign": "HB9HIL",
|
||||
"points": 10
|
||||
},
|
||||
{
|
||||
"callsign": "LA8AJA",
|
||||
"points": 10
|
||||
},
|
||||
{
|
||||
"callsign": "DB4SCW",
|
||||
"points": 5
|
||||
},
|
||||
{
|
||||
"callsign": "DG2RON",
|
||||
"points": 5
|
||||
},
|
||||
{
|
||||
"callsign": "DG0TM",
|
||||
"points": 5
|
||||
},
|
||||
{
|
||||
"callsign": "DO8MKR",
|
||||
"points": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"modeGroups": {},
|
||||
"achievements": [
|
||||
{
|
||||
"name": "Unicorn",
|
||||
"threshold": 10
|
||||
},
|
||||
{
|
||||
"name": "Few Devs",
|
||||
"threshold": 20
|
||||
},
|
||||
{
|
||||
"name": "More Devs",
|
||||
"threshold": 40
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"threshold": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
31
award-definitions/vucc6m.json
Normal file
31
award-definitions/vucc6m.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "vucc6m",
|
||||
"name": "VUCC 6M",
|
||||
"description": "Shows confirmed gridsquares on 6M",
|
||||
"caption": "Shows confirmed gridsquares on 6M",
|
||||
"category": "vucc",
|
||||
"rules": {
|
||||
"type": "entity",
|
||||
"satellite_only": false,
|
||||
"filters": {
|
||||
"operator": "AND",
|
||||
"filters": [
|
||||
{
|
||||
"field": "band",
|
||||
"operator": "eq",
|
||||
"value": "6m"
|
||||
}
|
||||
]
|
||||
},
|
||||
"entityType": "grid",
|
||||
"countMode": "perStation",
|
||||
"target": 100,
|
||||
"allowed_bands": [
|
||||
"6m"
|
||||
],
|
||||
"stations": [],
|
||||
"displayField": "grid"
|
||||
},
|
||||
"modeGroups": {},
|
||||
"achievements": []
|
||||
}
|
||||
33
award-definitions/wae.json
Normal file
33
award-definitions/wae.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "wae",
|
||||
"name": "WAE",
|
||||
"description": "Worked All Europe - Contact and confirm European countries from the WAE Country List",
|
||||
"caption": "Worked All Europe Award. Bandpoints: 1 point per band (2 points for 80m/160m), maximum 5 bands per country. Available in multiple mode variants.",
|
||||
"category": "darc",
|
||||
"modeGroups": {
|
||||
"CW": ["CW"],
|
||||
"SSB": ["SSB", "AM", "FM"],
|
||||
"RTTY": ["RTTY"],
|
||||
"FT8": ["FT8"],
|
||||
"Digi-Modes": ["FT4", "FT8", "JT65", "JT9", "MFSK", "PSK31", "RTTY"],
|
||||
"Classic Digi-Modes": ["PSK31", "RTTY"],
|
||||
"Mixed-Mode w/o WSJT-Modes": ["PSK31", "RTTY", "AM", "SSB", "FM", "CW"],
|
||||
"Phone-Modes": ["AM", "SSB", "FM"]
|
||||
},
|
||||
"rules": {
|
||||
"type": "wae",
|
||||
"targetCountries": 40,
|
||||
"targetBandpoints": 100,
|
||||
"doublePointBands": ["160m", "80m"],
|
||||
"maxBandsPerCountry": 5,
|
||||
"excludeDeletedForTop": true,
|
||||
"waeCountryList": "wae-country-list.json"
|
||||
},
|
||||
"achievements": [
|
||||
{ "name": "WAE III", "thresholdCountries": 40, "thresholdBandpoints": 100 },
|
||||
{ "name": "WAE II", "thresholdCountries": 50, "thresholdBandpoints": 150 },
|
||||
{ "name": "WAE I", "thresholdCountries": 60, "thresholdBandpoints": 200 },
|
||||
{ "name": "WAE TOP", "thresholdCountries": 70, "thresholdBandpoints": 300, "excludeDeleted": true },
|
||||
{ "name": "WAE Trophy", "thresholdCountries": 999, "thresholdBandpoints": 365, "requireAllCountries": true }
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"id": "was-mixed",
|
||||
"name": "WAS Mixed Mode",
|
||||
"name": "WAS",
|
||||
"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.",
|
||||
"category": "was",
|
||||
"achievements": [
|
||||
{ "name": "WAS Award", "threshold": 50 }
|
||||
],
|
||||
"rules": {
|
||||
"type": "entity",
|
||||
"entityType": "state",
|
||||
@@ -18,9 +15,21 @@
|
||||
{
|
||||
"field": "entityId",
|
||||
"operator": "in",
|
||||
"value": [291, 6, 110]
|
||||
"value": [
|
||||
291,
|
||||
6,
|
||||
110
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"stations": []
|
||||
},
|
||||
"modeGroups": {},
|
||||
"achievements": [
|
||||
{
|
||||
"name": "WAS Award",
|
||||
"threshold": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -276,6 +276,9 @@ award/
|
||||
callsign: text (not null)
|
||||
lotwUsername: text (nullable)
|
||||
lotwPassword: text (nullable, encrypted)
|
||||
isAdmin: boolean (default: false)
|
||||
isSuperAdmin: boolean (default: false)
|
||||
lastSeen: timestamp (nullable)
|
||||
createdAt: timestamp
|
||||
updatedAt: timestamp
|
||||
}
|
||||
@@ -1034,7 +1037,197 @@ When adding new awards or modifying the award system:
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
## Admin System
|
||||
|
||||
### Overview
|
||||
|
||||
The admin system provides user management, role-based access control, and account impersonation capabilities for support and administrative purposes.
|
||||
|
||||
### User Roles
|
||||
|
||||
The application supports three user roles with increasing permissions:
|
||||
|
||||
#### Regular User
|
||||
- View own QSOs and statistics
|
||||
- Sync from LoTW and DCL
|
||||
- Track award progress
|
||||
- Manage own credentials (LoTW, DCL)
|
||||
|
||||
#### Admin
|
||||
- All user permissions
|
||||
- View system-wide statistics
|
||||
- View all users and their activity
|
||||
- Promote/demote regular users to/from admin role
|
||||
- Delete regular users
|
||||
- Impersonate regular users (for support)
|
||||
- View admin action log
|
||||
|
||||
#### Super Admin
|
||||
- All admin permissions
|
||||
- Promote/demote admins to/from super-admin role
|
||||
- Impersonate other admins (for support)
|
||||
- Cannot be demoted by regular admins
|
||||
- Protected from accidental lockout
|
||||
|
||||
### Security Rules
|
||||
|
||||
**Role Change Restrictions:**
|
||||
- Only super-admins can promote or demote super-admins
|
||||
- Regular admins cannot promote users to super-admin
|
||||
- Super-admins cannot demote themselves
|
||||
- Cannot demote the last super-admin (prevents lockout)
|
||||
|
||||
**Impersonation Restrictions:**
|
||||
- Regular admins can only impersonate regular users
|
||||
- Super-admins can impersonate any user (including other admins)
|
||||
- All impersonation actions are logged to audit trail
|
||||
- Impersonation tokens expire after 1 hour
|
||||
|
||||
### Admin API Endpoints
|
||||
|
||||
**Statistics and Monitoring:**
|
||||
- `GET /api/admin/stats` - System-wide statistics (users, QSOs, jobs)
|
||||
- `GET /api/admin/users` - List all users with statistics
|
||||
- `GET /api/admin/users/:userId` - Get detailed user information
|
||||
- `GET /api/admin/actions` - View admin action log
|
||||
- `GET /api/admin/actions/my` - View current admin's actions
|
||||
|
||||
**User Management:**
|
||||
- `POST /api/admin/users/:userId/role` - Change user role
|
||||
- Body: `{ "role": "user" | "admin" | "super-admin" }`
|
||||
- `DELETE /api/admin/users/:userId` - Delete a user
|
||||
|
||||
**Impersonation:**
|
||||
- `POST /api/admin/impersonate/:userId` - Start impersonating a user
|
||||
- `POST /api/admin/impersonate/stop` - Stop impersonation
|
||||
- `GET /api/admin/impersonation/status` - Check impersonation status
|
||||
|
||||
### Admin Service
|
||||
|
||||
**File:** `src/backend/services/admin.service.js`
|
||||
|
||||
**Key Functions:**
|
||||
|
||||
```javascript
|
||||
// Check user permissions
|
||||
await isAdmin(userId)
|
||||
await isSuperAdmin(userId)
|
||||
|
||||
// Role management
|
||||
await changeUserRole(adminId, targetUserId, newRole)
|
||||
|
||||
// Impersonation
|
||||
await impersonateUser(adminId, targetUserId)
|
||||
await verifyImpersonation(impersonationToken)
|
||||
await stopImpersonation(adminId, targetUserId)
|
||||
|
||||
// Audit logging
|
||||
await logAdminAction(adminId, actionType, targetUserId, details)
|
||||
```
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All admin actions are logged to the `admin_actions` table for audit purposes:
|
||||
|
||||
**Action Types:**
|
||||
- `impersonate_start` - Started impersonating a user
|
||||
- `impersonate_stop` - Stopped impersonation
|
||||
- `role_change` - Changed user role
|
||||
- `user_delete` - Deleted a user
|
||||
|
||||
**Log Entry Structure:**
|
||||
```javascript
|
||||
{
|
||||
id: integer,
|
||||
adminId: integer,
|
||||
actionType: string,
|
||||
targetUserId: integer (nullable),
|
||||
details: string (JSON),
|
||||
createdAt: timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Admin Interface
|
||||
|
||||
**Route:** `/admin` (admin only)
|
||||
|
||||
**Features:**
|
||||
- **Overview Tab:** System statistics dashboard
|
||||
- **Users Tab:** User management with filtering
|
||||
- **Awards Tab:** Award definition management
|
||||
- **Action Log Tab:** Audit trail of admin actions
|
||||
|
||||
**User Management Actions:**
|
||||
- **Impersonate** - Switch to user account (disabled for admins unless super-admin)
|
||||
- **Promote/Demote** - Change user role
|
||||
- **Delete** - Remove user and all associated data
|
||||
|
||||
### JWT Token Claims
|
||||
|
||||
Admin tokens include additional claims:
|
||||
|
||||
```javascript
|
||||
{
|
||||
userId: number,
|
||||
email: string,
|
||||
callsign: string,
|
||||
isAdmin: boolean,
|
||||
isSuperAdmin: boolean, // New: Super-admin flag
|
||||
exp: number
|
||||
}
|
||||
```
|
||||
|
||||
**Impersonation Token:**
|
||||
```javascript
|
||||
{
|
||||
userId: number, // Target user ID
|
||||
email: string,
|
||||
callsign: string,
|
||||
isAdmin: boolean,
|
||||
isSuperAdmin: boolean,
|
||||
impersonatedBy: number, // Admin ID who started impersonation
|
||||
exp: number // 1 hour expiration
|
||||
}
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
**To create the first super-admin:**
|
||||
|
||||
1. Register a user account normally
|
||||
2. Access the database directly:
|
||||
```bash
|
||||
sqlite3 src/backend/award.db
|
||||
```
|
||||
|
||||
3. Update the user to super-admin:
|
||||
```sql
|
||||
UPDATE users SET is_super_admin = 1 WHERE email = 'your@email.com';
|
||||
```
|
||||
|
||||
4. Log out and log back in to get the updated JWT token
|
||||
|
||||
**To promote users via the admin interface:**
|
||||
1. Log in as a super-admin
|
||||
2. Navigate to `/admin`
|
||||
3. Find the user in the Users tab
|
||||
4. Click "Promote" and select "Super Admin"
|
||||
|
||||
### Production Deployment
|
||||
|
||||
After pulling the latest code:
|
||||
|
||||
```bash
|
||||
# Apply database migration (adds is_super_admin column)
|
||||
sqlite3 src/backend/award.db "ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0 NOT NULL;"
|
||||
|
||||
# Restart backend
|
||||
pm2 restart award-backend
|
||||
|
||||
# Promote a user to super-admin via database or existing admin interface
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
- [ARRL LoTW](https://lotw.arrl.org/)
|
||||
- [DARC Community Logbook (DCL)](https://dcl.darc.de/)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, sql, desc } from 'drizzle-orm';
|
||||
import { db, sqlite, logger } from '../config.js';
|
||||
import { users, qsos, syncJobs, adminActions, awardProgress, qsoChanges } from '../db/schema/index.js';
|
||||
import { getUserByIdFull, isAdmin, isSuperAdmin } from './auth.service.js';
|
||||
import { getUserByIdFull, isAdmin, isSuperAdmin, updateUserRole } from './auth.service.js';
|
||||
|
||||
/**
|
||||
* Log an admin action for audit trail
|
||||
|
||||
@@ -84,6 +84,7 @@ function loadAwardDefinitions() {
|
||||
*/
|
||||
export function clearAwardCache() {
|
||||
cachedAwardDefinitions = null;
|
||||
cachedWAECountryList = null;
|
||||
logger.info('Award cache cleared');
|
||||
}
|
||||
|
||||
@@ -242,6 +243,11 @@ export async function calculateAwardProgress(userId, award, options = {}) {
|
||||
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
||||
}
|
||||
|
||||
// Handle WAE-based awards (Worked All Europe)
|
||||
if (rules.type === 'wae') {
|
||||
return calculateWAEAwardProgress(userId, award, { includeDetails });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
@@ -768,6 +774,503 @@ function matchesFilter(qso, filter) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WAE (Worked All Europe) Award Functions
|
||||
// ============================================================================
|
||||
|
||||
// In-memory cache for WAE country list
|
||||
let cachedWAECountryList = null;
|
||||
|
||||
/**
|
||||
* Load WAE country list from JSON file
|
||||
*/
|
||||
function loadWAECountryList() {
|
||||
if (cachedWAECountryList) {
|
||||
return cachedWAECountryList;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = join(process.cwd(), 'award-data', 'wae-country-list.json');
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Build lookup maps for efficient matching
|
||||
const dxccMap = new Map();
|
||||
const waeSpecificMap = new Map();
|
||||
const deletedPrefixes = new Set();
|
||||
|
||||
// Index DXCC-based countries
|
||||
if (data.dxccBased) {
|
||||
for (const entry of data.dxccBased) {
|
||||
dxccMap.set(entry.entityId, {
|
||||
country: entry.country,
|
||||
prefix: entry.prefix,
|
||||
deleted: entry.deleted || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Index WAE-specific countries with callsign patterns
|
||||
if (data.waeSpecific) {
|
||||
for (const entry of data.waeSpecific) {
|
||||
waeSpecificMap.set(entry.prefix, {
|
||||
country: entry.country,
|
||||
prefix: entry.prefix,
|
||||
callsigns: entry.callsigns || [],
|
||||
parentDxcc: entry.parentDxcc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Index deleted countries
|
||||
if (data.deletedCountries) {
|
||||
for (const entry of data.deletedCountries) {
|
||||
deletedPrefixes.add(entry.prefix);
|
||||
}
|
||||
}
|
||||
|
||||
cachedWAECountryList = {
|
||||
dxccMap,
|
||||
waeSpecificMap,
|
||||
deletedPrefixes,
|
||||
rawData: data,
|
||||
};
|
||||
|
||||
logger.debug('WAE country list loaded', {
|
||||
dxccCount: dxccMap.size,
|
||||
waeSpecificCount: waeSpecificMap.size,
|
||||
deletedCount: deletedPrefixes.size,
|
||||
});
|
||||
|
||||
return cachedWAECountryList;
|
||||
} catch (error) {
|
||||
logger.error('Failed to load WAE country list', { error: error.message });
|
||||
return { dxccMap: new Map(), waeSpecificMap: new Map(), deletedPrefixes: new Set(), rawData: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a callsign to WAE country
|
||||
* Only matches if the country is explicitly in the WAE country list
|
||||
* @param {string} callsign - The callsign to match
|
||||
* @param {number} entityId - The DXCC entityId from QSO
|
||||
* @returns {Object|null} WAE country info or null if not a WAE country
|
||||
*/
|
||||
function matchWAECountry(callsign, entityId) {
|
||||
const waeList = loadWAECountryList();
|
||||
|
||||
if (!callsign) return null;
|
||||
|
||||
const normalizedCallsign = callsign.toUpperCase().trim();
|
||||
|
||||
// First check WAE-specific patterns (these override DXCC)
|
||||
for (const [prefix, info] of waeList.waeSpecificMap) {
|
||||
for (const pattern of info.callsigns) {
|
||||
if (pattern.includes('*')) {
|
||||
// Wildcard pattern matching
|
||||
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(normalizedCallsign)) {
|
||||
return {
|
||||
country: info.country,
|
||||
prefix: info.prefix,
|
||||
isDeleted: false,
|
||||
isWAESpecific: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if (normalizedCallsign === pattern) {
|
||||
return {
|
||||
country: info.country,
|
||||
prefix: info.prefix,
|
||||
isDeleted: false,
|
||||
isWAESpecific: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only match DXCC entities that are EXPLICITLY in the WAE country list
|
||||
// Do NOT fall back to matching any DXCC entity - WAE has its own list
|
||||
if (entityId && waeList.dxccMap.has(entityId)) {
|
||||
const dxccInfo = waeList.dxccMap.get(entityId);
|
||||
return {
|
||||
country: dxccInfo.country,
|
||||
prefix: dxccInfo.prefix,
|
||||
isDeleted: dxccInfo.deleted,
|
||||
isWAESpecific: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Not a WAE country (includes all non-European entities like US, JA, etc.)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bandpoint value for a band
|
||||
* @param {string} band - The band name
|
||||
* @param {Array} doublePointBands - Bands that count double
|
||||
* @returns {number} Point value for this band
|
||||
*/
|
||||
function getBandpointValue(band, doublePointBands = []) {
|
||||
if (doublePointBands && doublePointBands.includes(band)) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort bands by point value (descending) for max bands per country calculation
|
||||
* @param {Array} bands - Array of band names
|
||||
* @param {Array} doublePointBands - Bands that count double
|
||||
* @returns {Array} Bands sorted by point value
|
||||
*/
|
||||
function sortBandsByPointValue(bands, doublePointBands = []) {
|
||||
return bands.sort((a, b) => {
|
||||
const pointsA = getBandpointValue(a, doublePointBands);
|
||||
const pointsB = getBandpointValue(b, doublePointBands);
|
||||
if (pointsA !== pointsB) {
|
||||
return pointsB - pointsA; // Higher points first
|
||||
}
|
||||
// Tie-breaker: prefer lower frequency (longer wavelength) bands
|
||||
const bandOrder = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
|
||||
const indexA = bandOrder.indexOf(a);
|
||||
const indexB = bandOrder.indexOf(b);
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress for WAE awards
|
||||
* WAE tracks dual metrics: unique countries AND bandpoints
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
||||
*/
|
||||
async function calculateWAEAwardProgress(userId, award, options = {}) {
|
||||
const { includeDetails = false } = options;
|
||||
const { rules } = award;
|
||||
const {
|
||||
targetCountries = 40,
|
||||
targetBandpoints = 100,
|
||||
doublePointBands = ['160m', '80m'],
|
||||
maxBandsPerCountry = 5,
|
||||
excludeDeletedForTop = true,
|
||||
} = rules;
|
||||
|
||||
logger.debug('Calculating WAE award progress', {
|
||||
userId,
|
||||
awardId: award.id,
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
doublePointBands,
|
||||
maxBandsPerCountry,
|
||||
excludeDeletedForTop,
|
||||
});
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
logger.debug('Total QSOs for WAE calculation', { count: allQSOs.length });
|
||||
|
||||
// Track per-country data
|
||||
// Map: country -> { confirmed: boolean, bands: Set, bandpoints: number, qsos: [] }
|
||||
const countryData = new Map();
|
||||
|
||||
// Track all unique countries worked and confirmed
|
||||
const workedCountries = new Set();
|
||||
const confirmedCountries = new Set();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const waeCountry = matchWAECountry(qso.callsign, qso.entityId);
|
||||
|
||||
if (!waeCountry) {
|
||||
// Not a WAE country, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
const country = waeCountry.country;
|
||||
|
||||
// Track worked countries
|
||||
workedCountries.add(country);
|
||||
|
||||
// Check for LoTW confirmation
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
confirmedCountries.add(country);
|
||||
|
||||
// Initialize country data if not exists
|
||||
if (!countryData.has(country)) {
|
||||
countryData.set(country, {
|
||||
country,
|
||||
prefix: waeCountry.prefix,
|
||||
isDeleted: waeCountry.isDeleted,
|
||||
isWAESpecific: waeCountry.isWAESpecific,
|
||||
confirmed: true,
|
||||
bands: new Set(),
|
||||
bandpoints: 0,
|
||||
qsos: [],
|
||||
});
|
||||
}
|
||||
|
||||
const data = countryData.get(country);
|
||||
const band = qso.band || 'Unknown';
|
||||
|
||||
// Only count this band if we haven't seen it before for this country
|
||||
if (!data.bands.has(band)) {
|
||||
data.bands.add(band);
|
||||
|
||||
// Calculate bandpoints for this country
|
||||
// Get all confirmed bands for this country, sort by point value, take top N
|
||||
const allBands = Array.from(data.bands);
|
||||
const sortedBands = sortBandsByPointValue(allBands, doublePointBands);
|
||||
const bandsToCount = sortedBands.slice(0, maxBandsPerCountry);
|
||||
|
||||
// Recalculate total bandpoints
|
||||
let newBandpoints = 0;
|
||||
for (const b of bandsToCount) {
|
||||
newBandpoints += getBandpointValue(b, doublePointBands);
|
||||
}
|
||||
|
||||
data.bandpoints = newBandpoints;
|
||||
}
|
||||
|
||||
// Add QSO to the qsos array for drill-down
|
||||
data.qsos.push({
|
||||
qsoId: qso.id,
|
||||
callsign: qso.callsign,
|
||||
mode: qso.mode,
|
||||
qsoDate: qso.qsoDate,
|
||||
timeOn: qso.timeOn,
|
||||
band: qso.band,
|
||||
satName: qso.satName,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total bandpoints across all countries
|
||||
let totalBandpoints = 0;
|
||||
for (const data of countryData.values()) {
|
||||
totalBandpoints += data.bandpoints;
|
||||
}
|
||||
|
||||
// For WAE TOP/Trophy, we may need to exclude deleted countries
|
||||
let displayConfirmedCount = confirmedCountries.size;
|
||||
let displayWorkedCount = workedCountries.size;
|
||||
let displayTotalBandpoints = totalBandpoints;
|
||||
|
||||
// Check if this is for WAE TOP or Trophy (which exclude deleted countries)
|
||||
if (excludeDeletedForTop) {
|
||||
let confirmedWithoutDeleted = 0;
|
||||
for (const country of confirmedCountries) {
|
||||
const data = countryData.get(country);
|
||||
if (data && !data.isDeleted) {
|
||||
confirmedWithoutDeleted++;
|
||||
}
|
||||
}
|
||||
// Store both counts
|
||||
displayConfirmedCount = confirmedWithoutDeleted;
|
||||
}
|
||||
|
||||
logger.debug('WAE award progress calculated', {
|
||||
workedCountries: displayWorkedCount,
|
||||
confirmedCountries: displayConfirmedCount,
|
||||
totalBandpoints: displayTotalBandpoints,
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
});
|
||||
|
||||
// Build result
|
||||
const result = {
|
||||
worked: displayWorkedCount,
|
||||
confirmed: displayConfirmedCount,
|
||||
bandpoints: displayTotalBandpoints,
|
||||
workedBandpoints: displayTotalBandpoints, // For consistency with worked/confirmed naming
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
percentage: targetCountries ? Math.round((displayConfirmedCount / targetCountries) * 100) : 0,
|
||||
bandpointsPercentage: targetBandpoints ? Math.min(100, Math.round((displayTotalBandpoints / targetBandpoints) * 100)) : 0,
|
||||
workedEntities: Array.from(workedCountries),
|
||||
confirmedEntities: Array.from(confirmedCountries),
|
||||
};
|
||||
|
||||
// Add details if requested
|
||||
if (includeDetails) {
|
||||
result.award = {
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
caption: award.caption,
|
||||
targetCountries,
|
||||
targetBandpoints,
|
||||
};
|
||||
|
||||
// Build entities array for detail view
|
||||
// For WAE, we need to expand countries into (country, band, mode) slots
|
||||
// to match the frontend's expected (entity, band, mode) structure
|
||||
const expandedEntities = [];
|
||||
|
||||
for (const [countryName, data] of countryData) {
|
||||
const { bands, bandpoints, qsos, prefix, isDeleted, isWAESpecific } = data;
|
||||
|
||||
// For each band, create a slot entry
|
||||
for (const band of bands) {
|
||||
// Get all modes used on this band for this country
|
||||
const modesInBand = new Set();
|
||||
for (const qso of qsos) {
|
||||
if (qso.band === band) {
|
||||
modesInBand.add(qso.mode || 'Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
// Create a slot for each mode on this band
|
||||
for (const mode of modesInBand) {
|
||||
// Get QSOs for this specific (country, band, mode) combination
|
||||
const slotQSOs = qsos.filter(q => q.band === band && q.mode === mode);
|
||||
|
||||
expandedEntities.push({
|
||||
entity: countryName,
|
||||
entityId: null,
|
||||
entityName: countryName,
|
||||
prefix,
|
||||
isDeleted,
|
||||
isWAESpecific,
|
||||
band,
|
||||
mode,
|
||||
confirmed: true,
|
||||
bandpoints: getBandpointValue(band, award.rules.doublePointBands),
|
||||
worked: true,
|
||||
qsos: slotQSOs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no bands (shouldn't happen for confirmed countries, but handle edge case)
|
||||
if (bands.length === 0 && confirmedCountries.has(countryName)) {
|
||||
expandedEntities.push({
|
||||
entity: countryName,
|
||||
entityId: null,
|
||||
entityName: countryName,
|
||||
prefix,
|
||||
isDeleted,
|
||||
isWAESpecific,
|
||||
band: 'Unknown',
|
||||
mode: 'Unknown',
|
||||
confirmed: true,
|
||||
bandpoints: 0,
|
||||
worked: true,
|
||||
qsos: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.entities = expandedEntities;
|
||||
result.total = expandedEntities.length;
|
||||
result.confirmed = expandedEntities.filter((e) => e.confirmed).length;
|
||||
}
|
||||
|
||||
// Add achievement progress if award has achievements defined
|
||||
if (award.achievements && award.achievements.length > 0) {
|
||||
result.achievements = calculateWAEAchievementProgress(
|
||||
displayConfirmedCount,
|
||||
displayTotalBandpoints,
|
||||
award.achievements,
|
||||
excludeDeletedForTop
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate achievement progress for WAE awards (dual thresholds)
|
||||
* @param {number} confirmedCountries - Number of confirmed countries
|
||||
* @param {number} totalBandpoints - Total bandpoints earned
|
||||
* @param {Array} achievements - Array of achievement definitions
|
||||
* @param {boolean} excludeDeletedForTop - Whether deleted countries are excluded
|
||||
* @returns {Object} Achievement progress info
|
||||
*/
|
||||
function calculateWAEAchievementProgress(confirmedCountries, totalBandpoints, achievements, excludeDeletedForTop) {
|
||||
if (!achievements || achievements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort achievements by thresholdCountries
|
||||
const sorted = [...achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
|
||||
|
||||
// Find earned achievements, current level, and next level
|
||||
const earned = [];
|
||||
let currentLevel = null;
|
||||
let nextLevel = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const achievement = sorted[i];
|
||||
|
||||
// Check if achievement criteria are met
|
||||
// For achievements with excludeDeleted flag, we need both thresholds met
|
||||
// Otherwise, just check country and bandpoint thresholds
|
||||
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
|
||||
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
|
||||
|
||||
// Special handling for "requireAllCountries" (WAE Trophy)
|
||||
let allCountriesMet = false;
|
||||
if (achievement.requireAllCountries) {
|
||||
const waeList = loadWAECountryList();
|
||||
const totalCountries = waeList.dxccMap.size + waeList.waeSpecificMap.size;
|
||||
allCountriesMet = confirmedCountries >= totalCountries;
|
||||
}
|
||||
|
||||
const criteriaMet = achievement.requireAllCountries
|
||||
? (allCountriesMet && bandpointsMet)
|
||||
: (countriesMet && bandpointsMet);
|
||||
|
||||
if (criteriaMet) {
|
||||
earned.push(achievement);
|
||||
currentLevel = achievement;
|
||||
} else {
|
||||
nextLevel = achievement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress toward next level
|
||||
let progressPercent = 100;
|
||||
let progressCurrent = confirmedCountries;
|
||||
let progressNeeded = 0;
|
||||
let progressBandpointsCurrent = totalBandpoints;
|
||||
let progressBandpointsNeeded = 0;
|
||||
|
||||
if (nextLevel) {
|
||||
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
|
||||
const range = nextLevel.thresholdCountries - prevThreshold;
|
||||
const progressInLevel = confirmedCountries - prevThreshold;
|
||||
progressPercent = Math.round((progressInLevel / range) * 100);
|
||||
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
|
||||
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
|
||||
}
|
||||
|
||||
return {
|
||||
earned,
|
||||
currentLevel,
|
||||
nextLevel,
|
||||
progressPercent,
|
||||
progressCurrent: confirmedCountries,
|
||||
progressNeeded,
|
||||
progressBandpointsCurrent: totalBandpoints,
|
||||
progressBandpointsNeeded,
|
||||
totalAchievements: sorted.length,
|
||||
earnedCount: earned.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get award progress with QSO details
|
||||
*/
|
||||
@@ -851,6 +1354,11 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
||||
}
|
||||
|
||||
// Handle WAE-based awards - use the dedicated function
|
||||
if (rules.type === 'wae') {
|
||||
return await calculateWAEAwardProgress(userId, award, { includeDetails: true });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
@@ -896,7 +1404,19 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
}
|
||||
displayName = String(rawValue || entity);
|
||||
} else {
|
||||
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
||||
// Smart default based on entityType when displayField is not specified
|
||||
const defaultDisplayField = {
|
||||
'dxcc': 'entity',
|
||||
'state': 'state',
|
||||
'grid': 'grid',
|
||||
'callsign': 'callsign'
|
||||
}[rules.entityType] || 'entity';
|
||||
|
||||
let rawValue = qso[defaultDisplayField];
|
||||
if (defaultDisplayField === 'grid' && rawValue && rawValue.length > 4) {
|
||||
rawValue = rawValue.substring(0, 4);
|
||||
}
|
||||
displayName = String(rawValue || entity);
|
||||
}
|
||||
|
||||
if (!slotMap.has(slotKey)) {
|
||||
|
||||
@@ -552,7 +552,7 @@ export async function getQSOStats(userId) {
|
||||
}).from(qsos).where(eq(qsos.userId, userId)),
|
||||
|
||||
db.select({
|
||||
uniqueEntities: sql`CAST(COUNT(DISTINCT entity) AS INTEGER)`,
|
||||
uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
|
||||
uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
|
||||
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
|
||||
}).from(qsos).where(eq(qsos.userId, userId))
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
@@ -199,7 +199,7 @@
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -226,7 +226,7 @@
|
||||
}
|
||||
|
||||
.impersonation-content {
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -561,7 +561,7 @@
|
||||
<style>
|
||||
.admin-dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
'dok': 'DOK',
|
||||
'points': 'Points',
|
||||
'filtered': 'Filtered',
|
||||
'counter': 'Counter'
|
||||
'counter': 'Counter',
|
||||
'wae': 'WAE'
|
||||
};
|
||||
return names[ruleType] || ruleType;
|
||||
}
|
||||
@@ -168,7 +169,7 @@
|
||||
<style>
|
||||
.awards-admin {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
case 'counter':
|
||||
validateCounterRule(errors, warnings);
|
||||
break;
|
||||
case 'wae':
|
||||
validateWAERule(errors, warnings);
|
||||
break;
|
||||
}
|
||||
|
||||
// Mode groups validation
|
||||
@@ -236,6 +239,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function validateWAERule(errors, warnings) {
|
||||
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
|
||||
errors.push('WAE rule requires targetCountries (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
|
||||
errors.push('WAE rule requires targetBandpoints (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
|
||||
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
|
||||
}
|
||||
|
||||
// Check that double-point bands are valid
|
||||
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
|
||||
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
|
||||
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
|
||||
if (invalid.length > 0) {
|
||||
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateModeGroups(errors, warnings) {
|
||||
if (!formData.modeGroups) return;
|
||||
|
||||
@@ -774,6 +800,7 @@
|
||||
<option value="points">Points (sum points from stations)</option>
|
||||
<option value="filtered">Filtered (base rule with filters)</option>
|
||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||
<option value="wae">WAE (Worked All Europe)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -804,7 +831,7 @@
|
||||
bind:value={formData.rules.displayField}
|
||||
placeholder="e.g., entity, state, grid"
|
||||
/>
|
||||
<small>Field to display as entity name (defaults to entity value)</small>
|
||||
<small>Field to display as entity name (defaults based on entityType)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -995,6 +1022,93 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if formData.rules.type === 'wae'}
|
||||
<div class="rule-config">
|
||||
<h3>WAE Rule Configuration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Countries *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetCountries}
|
||||
placeholder="40"
|
||||
/>
|
||||
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Bandpoints *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetBandpoints}
|
||||
placeholder="100"
|
||||
/>
|
||||
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Double-Point Bands</label>
|
||||
<div class="bands-selector">
|
||||
{#each ['160m', '80m'] as band}
|
||||
<label class="band-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rules.doublePointBands?.includes(band)}
|
||||
on:change={(e) => {
|
||||
if (!formData.rules.doublePointBands) {
|
||||
formData = {
|
||||
...formData,
|
||||
rules: {
|
||||
...formData.rules,
|
||||
doublePointBands: []
|
||||
}
|
||||
};
|
||||
}
|
||||
if (e.target.checked) {
|
||||
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
|
||||
} else {
|
||||
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{band}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<small>Bands that count double points (typically 160m and 80m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Maximum Bands Per Country *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={formData.rules.maxBandsPerCountry}
|
||||
placeholder="5"
|
||||
/>
|
||||
<small>Only top N bands count per country (default: 5)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.rules.excludeDeletedForTop}
|
||||
/>
|
||||
Exclude deleted countries for WAE TOP/Trophy
|
||||
</label>
|
||||
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
|
||||
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
|
||||
with a maximum of 5 bands per country.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters section (common to all rule types) -->
|
||||
@@ -1023,7 +1137,7 @@
|
||||
<style>
|
||||
.award-editor {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,15 +197,21 @@
|
||||
}
|
||||
|
||||
// Check if displayField matches the default for the entity type
|
||||
if (rules.entityType && rules.displayField) {
|
||||
if (rules.entityType) {
|
||||
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.`);
|
||||
const defaultField = defaults[rules.entityType];
|
||||
|
||||
if (rules.displayField) {
|
||||
if (defaultField === rules.displayField) {
|
||||
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
|
||||
}
|
||||
} else if (defaultField) {
|
||||
info.push(`displayField will default to "${defaultField}" for entityType="${rules.entityType}".`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,21 @@
|
||||
stationsStore.set(formData.rules.stations.map(s => ({...s})));
|
||||
}
|
||||
|
||||
// Initialize WAE-specific fields when rule type changes to 'wae' (only for new awards, not editing)
|
||||
$: if (formData.rules?.type === 'wae' && formData.rules.targetCountries === undefined && !isEdit) {
|
||||
formData = {
|
||||
...formData,
|
||||
rules: {
|
||||
...formData.rules,
|
||||
targetCountries: 40,
|
||||
targetBandpoints: 100,
|
||||
doublePointBands: ['160m', '80m'],
|
||||
maxBandsPerCountry: 5,
|
||||
excludeDeletedForTop: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Available bands and modes
|
||||
const ALL_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
|
||||
const ALL_MODES = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
|
||||
@@ -57,12 +72,11 @@
|
||||
onMount(() => {
|
||||
// Check if we're editing an existing award
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts.includes('[id]') || pathParts.length > 5) {
|
||||
// Extract award ID from path
|
||||
const idPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
|
||||
if (idPart && idPart !== 'create') {
|
||||
loadAward(idPart);
|
||||
}
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
|
||||
// If the last part is not 'create' and looks like an award ID, load the award
|
||||
if (lastPart && lastPart !== 'create' && lastPart !== 'awards') {
|
||||
loadAward(lastPart);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,6 +142,9 @@
|
||||
case 'counter':
|
||||
validateCounterRule(errors, warnings);
|
||||
break;
|
||||
case 'wae':
|
||||
validateWAERule(errors, warnings);
|
||||
break;
|
||||
}
|
||||
|
||||
// Mode groups validation
|
||||
@@ -243,6 +260,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
function validateWAERule(errors, warnings) {
|
||||
if (!formData.rules.targetCountries || formData.rules.targetCountries <= 0) {
|
||||
errors.push('WAE rule requires targetCountries (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.targetBandpoints || formData.rules.targetBandpoints <= 0) {
|
||||
errors.push('WAE rule requires targetBandpoints (positive number)');
|
||||
}
|
||||
|
||||
if (!formData.rules.maxBandsPerCountry || formData.rules.maxBandsPerCountry <= 0) {
|
||||
warnings.push('WAE rule should have maxBandsPerCountry (default: 5)');
|
||||
}
|
||||
|
||||
// Check that double-point bands are valid
|
||||
if (formData.rules.doublePointBands && Array.isArray(formData.rules.doublePointBands)) {
|
||||
const validDoublePointBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
|
||||
const invalid = formData.rules.doublePointBands.filter(b => !validDoublePointBands.includes(b));
|
||||
if (invalid.length > 0) {
|
||||
warnings.push(`Unusual double-point bands: ${invalid.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateModeGroups(errors, warnings) {
|
||||
if (!formData.modeGroups) return;
|
||||
|
||||
@@ -799,6 +839,7 @@
|
||||
<option value="points">Points (sum points from stations)</option>
|
||||
<option value="filtered">Filtered (base rule with filters)</option>
|
||||
<option value="counter">Counter (count QSOs or callsigns)</option>
|
||||
<option value="wae">WAE (Worked All Europe)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -829,7 +870,7 @@
|
||||
bind:value={formData.rules.displayField}
|
||||
placeholder="e.g., entity, state, grid"
|
||||
/>
|
||||
<small>Field to display as entity name (defaults to entity value)</small>
|
||||
<small>Field to display as entity name (defaults based on entityType)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -1021,6 +1062,93 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if formData.rules.type === 'wae'}
|
||||
<div class="rule-config">
|
||||
<h3>WAE Rule Configuration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Countries *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetCountries}
|
||||
placeholder="40"
|
||||
/>
|
||||
<small>Number of unique WAE countries required (e.g., 40 for WAE III)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Target Bandpoints *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.rules.targetBandpoints}
|
||||
placeholder="100"
|
||||
/>
|
||||
<small>Total bandpoints required (1 per band, 2 for 80m/160m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Double-Point Bands</label>
|
||||
<div class="bands-selector">
|
||||
{#each ['160m', '80m'] as band}
|
||||
<label class="band-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.rules.doublePointBands?.includes(band)}
|
||||
on:change={(e) => {
|
||||
if (!formData.rules.doublePointBands) {
|
||||
formData = {
|
||||
...formData,
|
||||
rules: {
|
||||
...formData.rules,
|
||||
doublePointBands: []
|
||||
}
|
||||
};
|
||||
}
|
||||
if (e.target.checked) {
|
||||
formData.rules.doublePointBands = [...formData.rules.doublePointBands, band];
|
||||
} else {
|
||||
formData.rules.doublePointBands = formData.rules.doublePointBands.filter(b => b !== band);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{band}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<small>Bands that count double points (typically 160m and 80m)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Maximum Bands Per Country *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
bind:value={formData.rules.maxBandsPerCountry}
|
||||
placeholder="5"
|
||||
/>
|
||||
<small>Only top N bands count per country (default: 5)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.rules.excludeDeletedForTop}
|
||||
/>
|
||||
Exclude deleted countries for WAE TOP/Trophy
|
||||
</label>
|
||||
<small>WAE TOP and Trophy exclude deleted countries from the count</small>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>WAE Award Info:</strong> The WAE award tracks dual metrics - unique countries
|
||||
AND bandpoints. Each confirmed country counts 1 bandpoint per band (2 points for 80m/160m),
|
||||
with a maximum of 5 bands per country.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters section (common to all rule types) -->
|
||||
@@ -1049,7 +1177,7 @@
|
||||
<style>
|
||||
.award-editor {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
@@ -539,6 +539,11 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle WAE awards with dual thresholds
|
||||
if (award.rules?.type === 'wae') {
|
||||
return calculateWAEAchievementProgress(award, entities);
|
||||
}
|
||||
|
||||
// Get current count (confirmed entities or points)
|
||||
let currentCount;
|
||||
if (entities.length > 0 && entities[0].points !== undefined) {
|
||||
@@ -598,6 +603,85 @@
|
||||
earnedCount: earned.length,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateWAEAchievementProgress(award, entities) {
|
||||
// Count unique confirmed countries and total bandpoints
|
||||
const uniqueCountries = new Set();
|
||||
let totalBandpoints = 0;
|
||||
|
||||
entities.forEach(e => {
|
||||
if (e.confirmed && !e.isDeleted) {
|
||||
uniqueCountries.add(e.entityName || e.entity || 'Unknown');
|
||||
}
|
||||
totalBandpoints += e.bandpoints || 0;
|
||||
});
|
||||
|
||||
const confirmedCountries = uniqueCountries.size;
|
||||
|
||||
// Sort achievements by thresholdCountries
|
||||
const sorted = [...award.achievements].sort((a, b) => a.thresholdCountries - b.thresholdCountries);
|
||||
|
||||
// Find earned achievements, current level, and next level
|
||||
const earned = [];
|
||||
let currentLevel = null;
|
||||
let nextLevel = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const achievement = sorted[i];
|
||||
|
||||
// Check if both thresholds are met
|
||||
const countriesMet = confirmedCountries >= achievement.thresholdCountries;
|
||||
const bandpointsMet = totalBandpoints >= achievement.thresholdBandpoints;
|
||||
|
||||
// Special handling for "requireAllCountries" (WAE Trophy)
|
||||
let allCountriesMet = false;
|
||||
if (achievement.requireAllCountries) {
|
||||
// This would require knowing total WAE countries - for now use a large number
|
||||
allCountriesMet = confirmedCountries >= 75; // Approximate WAE country count
|
||||
}
|
||||
|
||||
const criteriaMet = achievement.requireAllCountries
|
||||
? (allCountriesMet && bandpointsMet)
|
||||
: (countriesMet && bandpointsMet);
|
||||
|
||||
if (criteriaMet) {
|
||||
earned.push(achievement);
|
||||
currentLevel = achievement;
|
||||
} else {
|
||||
nextLevel = achievement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress toward next level
|
||||
let progressPercent = 100;
|
||||
let progressCurrent = confirmedCountries;
|
||||
let progressNeeded = 0;
|
||||
let progressBandpointsCurrent = totalBandpoints;
|
||||
let progressBandpointsNeeded = 0;
|
||||
|
||||
if (nextLevel) {
|
||||
const prevThreshold = currentLevel ? currentLevel.thresholdCountries : 0;
|
||||
const range = nextLevel.thresholdCountries - prevThreshold;
|
||||
const progressInLevel = confirmedCountries - prevThreshold;
|
||||
progressPercent = Math.round((progressInLevel / range) * 100);
|
||||
progressNeeded = nextLevel.thresholdCountries - confirmedCountries;
|
||||
progressBandpointsNeeded = nextLevel.thresholdBandpoints - totalBandpoints;
|
||||
}
|
||||
|
||||
return {
|
||||
earned,
|
||||
currentLevel,
|
||||
nextLevel,
|
||||
progressPercent,
|
||||
progressCurrent,
|
||||
progressNeeded,
|
||||
progressBandpointsCurrent,
|
||||
progressBandpointsNeeded,
|
||||
totalAchievements: sorted.length,
|
||||
earnedCount: earned.length,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
@@ -619,7 +703,34 @@
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
{#if entities.length > 0 && entities[0].points !== undefined}
|
||||
{#if award?.rules?.type === 'wae'}
|
||||
{@const targetCountries = award.rules?.targetCountries}
|
||||
{@const targetBandpoints = award.rules?.targetBandpoints}
|
||||
{@const confirmedCountries = uniqueEntityProgress.confirmed}
|
||||
{@const workedCountries = uniqueEntityProgress.worked}
|
||||
{@const neededCountries = targetCountries ? Math.max(0, targetCountries - confirmedCountries) : null}
|
||||
{@const totalBandpoints = filteredEntities.reduce((sum, e) => sum + (e.bandpoints || 0), 0)}
|
||||
{@const neededBandpoints = targetBandpoints ? Math.max(0, targetBandpoints - totalBandpoints) : null}
|
||||
|
||||
<div class="summary-card wae-countries">
|
||||
<span class="summary-label">Countries:</span>
|
||||
<span class="summary-value">{confirmedCountries} / {targetCountries}</span>
|
||||
</div>
|
||||
<div class="summary-card wae-bandpoints">
|
||||
<span class="summary-label">Bandpoints:</span>
|
||||
<span class="summary-value">{totalBandpoints} / {targetBandpoints}</span>
|
||||
</div>
|
||||
{#if neededCountries !== null}
|
||||
<div class="summary-card unworked">
|
||||
<span class="summary-label">Need:</span>
|
||||
<span class="summary-value">{neededCountries} countries</span>
|
||||
</div>
|
||||
<div class="summary-card unworked">
|
||||
<span class="summary-label">Need:</span>
|
||||
<span class="summary-value">{neededBandpoints} bandpoints</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if entities.length > 0 && entities[0].points !== undefined}
|
||||
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||
{@const targetPoints = award.rules?.target}
|
||||
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
||||
@@ -631,7 +742,7 @@
|
||||
<span class="summary-label">Confirmed:</span>
|
||||
<span class="summary-value">{filteredEntities.filter((e) => e.confirmed).length}</span>
|
||||
</div>
|
||||
<div class="summary-card" style="background-color: #fff3cd; border-color: #ffc107;">
|
||||
<div class="summary-card points">
|
||||
<span class="summary-label">Points:</span>
|
||||
<span class="summary-value">{earnedPoints}</span>
|
||||
</div>
|
||||
@@ -640,7 +751,7 @@
|
||||
<span class="summary-label">Needed:</span>
|
||||
<span class="summary-value">{neededPoints}</span>
|
||||
</div>
|
||||
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
|
||||
<div class="summary-card target">
|
||||
<span class="summary-label">Target:</span>
|
||||
<span class="summary-value">{targetPoints}</span>
|
||||
</div>
|
||||
@@ -665,7 +776,7 @@
|
||||
<span class="summary-label">Needed:</span>
|
||||
<span class="summary-value">{neededCount}</span>
|
||||
</div>
|
||||
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
|
||||
<div class="summary-card target">
|
||||
<span class="summary-label">Target:</span>
|
||||
<span class="summary-value">{targetCount}</span>
|
||||
</div>
|
||||
@@ -685,7 +796,11 @@
|
||||
<div class="achievement-badge earned">
|
||||
<span class="achievement-icon">★</span>
|
||||
<span class="achievement-name">{achievement.name}</span>
|
||||
<span class="achievement-threshold">{achievement.threshold}</span>
|
||||
{#if achievement.thresholdCountries !== undefined}
|
||||
<span class="achievement-threshold">{achievement.thresholdCountries} countries / {achievement.thresholdBandpoints} bandpoints</span>
|
||||
{:else}
|
||||
<span class="achievement-threshold">{achievement.threshold}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -696,9 +811,15 @@
|
||||
<div class="next-achievement">
|
||||
<div class="next-achievement-header">
|
||||
<span class="next-achievement-title">Next: {achievementProgress.nextLevel.name}</span>
|
||||
<span class="next-achievement-count">
|
||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
|
||||
</span>
|
||||
{#if achievementProgress.nextLevel.thresholdCountries !== undefined}
|
||||
<span class="next-achievement-count">
|
||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.thresholdCountries} countries
|
||||
</span>
|
||||
{:else}
|
||||
<span class="next-achievement-count">
|
||||
{achievementProgress.progressCurrent} / {achievementProgress.nextLevel.threshold}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="achievement-progress-bar">
|
||||
<div
|
||||
@@ -707,7 +828,13 @@
|
||||
></div>
|
||||
</div>
|
||||
<div class="next-achievement-footer">
|
||||
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
|
||||
{#if achievementProgress.progressBandpointsNeeded !== undefined}
|
||||
<span class="needed-text">
|
||||
{achievementProgress.progressNeeded} countries and {achievementProgress.progressBandpointsNeeded} bandpoints more to {achievementProgress.nextLevel.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="needed-text">{achievementProgress.progressNeeded} more to {achievementProgress.nextLevel.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1022,7 +1149,7 @@
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -1141,6 +1268,38 @@
|
||||
background-color: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.summary-card.points {
|
||||
border-color: #ff9800;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:global(.dark) .summary-card.points {
|
||||
background-color: rgba(255, 152, 0, 0.15);
|
||||
}
|
||||
|
||||
.summary-card.target {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:global(.dark) .summary-card.target {
|
||||
background-color: rgba(33, 150, 243, 0.15);
|
||||
}
|
||||
|
||||
.summary-card.wae-countries {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.summary-card.wae-bandpoints {
|
||||
border-color: #9c27b0;
|
||||
background-color: rgba(156, 39, 176, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .summary-card.wae-bandpoints {
|
||||
background-color: rgba(156, 39, 176, 0.2);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -877,7 +877,7 @@
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
disabled={isRunning || deleting}
|
||||
>
|
||||
{#if isRunning}
|
||||
<span class="spinner"></span>
|
||||
{label} Syncing...
|
||||
{:else}
|
||||
Sync from {label}
|
||||
@@ -37,4 +38,22 @@
|
||||
.dcl-btn:hover:not(:disabled) {
|
||||
background-color: #d35400;
|
||||
}
|
||||
|
||||
/* Spinner animation */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -440,40 +440,46 @@
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--text-inverted);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #5a6268;
|
||||
background-color: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
background: #f8f9fa;
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.user-info h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
margin: 0.5rem 0;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-info :global(strong),
|
||||
.settings-form :global(strong),
|
||||
.next-sync-info :global(strong) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
@@ -483,44 +489,44 @@
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
background-color: var(--color-info-bg);
|
||||
border: 1px solid var(--color-info);
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
background-color: var(--color-error-bg);
|
||||
border: 1px solid var(--color-error);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
background-color: var(--color-success-bg);
|
||||
border: 1px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
background: white;
|
||||
background: var(--bg-card);
|
||||
padding: 2rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -532,34 +538,36 @@
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -567,12 +575,12 @@
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #357abd;
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
@@ -581,34 +589,34 @@
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
background-color: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #e8f4fd;
|
||||
border-left: 4px solid #4a90e2;
|
||||
background: var(--color-info-bg);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0.5rem 0;
|
||||
color: #666;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box a {
|
||||
color: #4a90e2;
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -634,6 +642,7 @@
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
@@ -649,18 +658,18 @@
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.next-sync-info {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #4a90e2;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-info-bg);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
Reference in New Issue
Block a user