Compare commits
12 Commits
310b1547c4
...
docker
| Author | SHA1 | Date | |
|---|---|---|---|
|
ae4e60f966
|
|||
|
dbca64a03c
|
|||
|
c56226e05b
|
|||
|
8f8abfc651
|
|||
|
fc44fef91a
|
|||
|
7026f2bca7
|
|||
|
e88537754f
|
|||
|
fe305310b9
|
|||
|
1b0cc4441f
|
|||
|
21263e6735
|
|||
|
db0145782a
|
|||
|
2aebfb0771
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@ coverage
|
|||||||
# logs
|
# logs
|
||||||
logs/*.log
|
logs/*.log
|
||||||
logs
|
logs
|
||||||
|
backend.log
|
||||||
|
frontend.log
|
||||||
_.log
|
_.log
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
!logs/.gitkeep
|
!logs/.gitkeep
|
||||||
|
|||||||
97
CLAUDE.md
97
CLAUDE.md
@@ -709,3 +709,100 @@ if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
|||||||
- Clarified that DCL API doesn't send DXCC fields (current limitation)
|
- Clarified that DCL API doesn't send DXCC fields (current limitation)
|
||||||
- Implemented priority logic: LoTW entity data takes precedence over DCL
|
- 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
|
- 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**
|
||||||
|
|
||||||
|
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:
|
||||||
|
- `CALL` (callsign)
|
||||||
|
- `QSL_RCVD` (confirmation status: Y/N)
|
||||||
|
|
||||||
|
**Missing Fields for Unconfirmed QSOs:**
|
||||||
|
- `DXCC` (entity ID) ← **CRITICAL for awards!**
|
||||||
|
- `COUNTRY` (entity name)
|
||||||
|
- `CONTINENT`
|
||||||
|
- `CQ_ZONE`
|
||||||
|
- `ITU_ZONE`
|
||||||
|
|
||||||
|
**Result:** Unconfirmed QSOs have `entityId: null` and `entity: ""`, breaking award calculations.
|
||||||
|
|
||||||
|
**Current Implementation (CORRECT):**
|
||||||
|
```javascript
|
||||||
|
// lotw.service.js - fetchQSOsFromLoTW()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
login: lotwUsername,
|
||||||
|
password: loTWPassword,
|
||||||
|
qso_query: '1',
|
||||||
|
qso_qsl: 'yes', // ONLY confirmed QSOs
|
||||||
|
qso_qslsince: dateStr, // Incremental sync
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Matters:**
|
||||||
|
- 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):**
|
||||||
|
- Tried implementing callsign prefix lookup to populate missing `entityId`
|
||||||
|
- Created `src/backend/utils/callsign-lookup.js` with basic prefix mappings
|
||||||
|
- Complexity: 1000+ DXCC entities, many special event callsigns, portable designators
|
||||||
|
- Decision: Too complex, reverted (commit 310b154)
|
||||||
|
|
||||||
|
**Takeaway:** LoTW confirmed QSOs have reliable DXCC data. Don't try to workaround this fundamental limitation.
|
||||||
|
|
||||||
|
### QSO Confirmation Filters
|
||||||
|
|
||||||
|
Added "Confirmed by at least 1 service" filter to QSO view (commit 688b0fc):
|
||||||
|
|
||||||
|
**Filter Options:**
|
||||||
|
- "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
|
||||||
|
|||||||
23
award-definitions/73-on-73.json
Normal file
23
award-definitions/73-on-73.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "73-on-73",
|
||||||
|
"name": "73 on 73",
|
||||||
|
"description": "Confirm 73 unique QSO partners on satellite AO-73",
|
||||||
|
"caption": "Contact and confirm 73 different stations (unique callsigns) via the AO-73 satellite. Each unique callsign confirmed via LoTW counts toward the total of 73.",
|
||||||
|
"category": "satellite",
|
||||||
|
"rules": {
|
||||||
|
"type": "entity",
|
||||||
|
"entityType": "callsign",
|
||||||
|
"target": 73,
|
||||||
|
"displayField": "callsign",
|
||||||
|
"filters": {
|
||||||
|
"operator": "AND",
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"field": "satName",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": "AO-73"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
drizzle/0002_nervous_layla_miller.sql
Normal file
25
drizzle/0002_nervous_layla_miller.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE `admin_actions` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`admin_id` integer NOT NULL,
|
||||||
|
`action_type` text NOT NULL,
|
||||||
|
`target_user_id` integer,
|
||||||
|
`details` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`admin_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `qso_changes` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`job_id` integer NOT NULL,
|
||||||
|
`qso_id` integer,
|
||||||
|
`change_type` text NOT NULL,
|
||||||
|
`before_data` text,
|
||||||
|
`after_data` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`job_id`) REFERENCES `sync_jobs`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`qso_id`) REFERENCES `qsos`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `role` text DEFAULT 'user' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `is_admin` integer DEFAULT false NOT NULL;
|
||||||
1
drizzle/0003_tired_warpath.sql
Normal file
1
drizzle/0003_tired_warpath.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` DROP COLUMN `role`;
|
||||||
756
drizzle/meta/0002_snapshot.json
Normal file
756
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "542bddc5-2e08-49af-91b5-013a6c9584df",
|
||||||
|
"prevId": "b5c00e60-2f3c-4c2b-a540-0be8d9e856e6",
|
||||||
|
"tables": {
|
||||||
|
"admin_actions": {
|
||||||
|
"name": "admin_actions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"admin_id": {
|
||||||
|
"name": "admin_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"action_type": {
|
||||||
|
"name": "action_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"target_user_id": {
|
||||||
|
"name": "target_user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"name": "details",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"admin_actions_admin_id_users_id_fk": {
|
||||||
|
"name": "admin_actions_admin_id_users_id_fk",
|
||||||
|
"tableFrom": "admin_actions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"admin_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"admin_actions_target_user_id_users_id_fk": {
|
||||||
|
"name": "admin_actions_target_user_id_users_id_fk",
|
||||||
|
"tableFrom": "admin_actions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"target_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"award_progress": {
|
||||||
|
"name": "award_progress",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"award_id": {
|
||||||
|
"name": "award_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"worked_count": {
|
||||||
|
"name": "worked_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"confirmed_count": {
|
||||||
|
"name": "confirmed_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"total_required": {
|
||||||
|
"name": "total_required",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"worked_entities": {
|
||||||
|
"name": "worked_entities",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"confirmed_entities": {
|
||||||
|
"name": "confirmed_entities",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_calculated_at": {
|
||||||
|
"name": "last_calculated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_qso_sync_at": {
|
||||||
|
"name": "last_qso_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"award_progress_user_id_users_id_fk": {
|
||||||
|
"name": "award_progress_user_id_users_id_fk",
|
||||||
|
"tableFrom": "award_progress",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"award_progress_award_id_awards_id_fk": {
|
||||||
|
"name": "award_progress_award_id_awards_id_fk",
|
||||||
|
"tableFrom": "award_progress",
|
||||||
|
"tableTo": "awards",
|
||||||
|
"columnsFrom": [
|
||||||
|
"award_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"awards": {
|
||||||
|
"name": "awards",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"definition": {
|
||||||
|
"name": "definition",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"qso_changes": {
|
||||||
|
"name": "qso_changes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"job_id": {
|
||||||
|
"name": "job_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"qso_id": {
|
||||||
|
"name": "qso_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"change_type": {
|
||||||
|
"name": "change_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before_data": {
|
||||||
|
"name": "before_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after_data": {
|
||||||
|
"name": "after_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"qso_changes_job_id_sync_jobs_id_fk": {
|
||||||
|
"name": "qso_changes_job_id_sync_jobs_id_fk",
|
||||||
|
"tableFrom": "qso_changes",
|
||||||
|
"tableTo": "sync_jobs",
|
||||||
|
"columnsFrom": [
|
||||||
|
"job_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"qso_changes_qso_id_qsos_id_fk": {
|
||||||
|
"name": "qso_changes_qso_id_qsos_id_fk",
|
||||||
|
"tableFrom": "qso_changes",
|
||||||
|
"tableTo": "qsos",
|
||||||
|
"columnsFrom": [
|
||||||
|
"qso_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"qsos": {
|
||||||
|
"name": "qsos",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"callsign": {
|
||||||
|
"name": "callsign",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"qso_date": {
|
||||||
|
"name": "qso_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"time_on": {
|
||||||
|
"name": "time_on",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"band": {
|
||||||
|
"name": "band",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"name": "mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"freq": {
|
||||||
|
"name": "freq",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"freq_rx": {
|
||||||
|
"name": "freq_rx",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"name": "entity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "entity_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"name": "grid",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grid_source": {
|
||||||
|
"name": "grid_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"continent": {
|
||||||
|
"name": "continent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cq_zone": {
|
||||||
|
"name": "cq_zone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"itu_zone": {
|
||||||
|
"name": "itu_zone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"county": {
|
||||||
|
"name": "county",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sat_name": {
|
||||||
|
"name": "sat_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sat_mode": {
|
||||||
|
"name": "sat_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"my_darc_dok": {
|
||||||
|
"name": "my_darc_dok",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"darc_dok": {
|
||||||
|
"name": "darc_dok",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_qsl_rdate": {
|
||||||
|
"name": "lotw_qsl_rdate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_qsl_rstatus": {
|
||||||
|
"name": "lotw_qsl_rstatus",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_qsl_rdate": {
|
||||||
|
"name": "dcl_qsl_rdate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_qsl_rstatus": {
|
||||||
|
"name": "dcl_qsl_rstatus",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_synced_at": {
|
||||||
|
"name": "lotw_synced_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"qsos_user_id_users_id_fk": {
|
||||||
|
"name": "qsos_user_id_users_id_fk",
|
||||||
|
"tableFrom": "qsos",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sync_jobs": {
|
||||||
|
"name": "sync_jobs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"name": "result",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sync_jobs_user_id_users_id_fk": {
|
||||||
|
"name": "sync_jobs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sync_jobs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"callsign": {
|
||||||
|
"name": "callsign",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_username": {
|
||||||
|
"name": "lotw_username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_password": {
|
||||||
|
"name": "lotw_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_api_key": {
|
||||||
|
"name": "dcl_api_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
748
drizzle/meta/0003_snapshot.json
Normal file
748
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "071c98fb-6721-4da7-98cb-c16cb6aaf0c1",
|
||||||
|
"prevId": "542bddc5-2e08-49af-91b5-013a6c9584df",
|
||||||
|
"tables": {
|
||||||
|
"admin_actions": {
|
||||||
|
"name": "admin_actions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"admin_id": {
|
||||||
|
"name": "admin_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"action_type": {
|
||||||
|
"name": "action_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"target_user_id": {
|
||||||
|
"name": "target_user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"name": "details",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"admin_actions_admin_id_users_id_fk": {
|
||||||
|
"name": "admin_actions_admin_id_users_id_fk",
|
||||||
|
"tableFrom": "admin_actions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"admin_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"admin_actions_target_user_id_users_id_fk": {
|
||||||
|
"name": "admin_actions_target_user_id_users_id_fk",
|
||||||
|
"tableFrom": "admin_actions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"target_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"award_progress": {
|
||||||
|
"name": "award_progress",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"award_id": {
|
||||||
|
"name": "award_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"worked_count": {
|
||||||
|
"name": "worked_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"confirmed_count": {
|
||||||
|
"name": "confirmed_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"total_required": {
|
||||||
|
"name": "total_required",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"worked_entities": {
|
||||||
|
"name": "worked_entities",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"confirmed_entities": {
|
||||||
|
"name": "confirmed_entities",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_calculated_at": {
|
||||||
|
"name": "last_calculated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_qso_sync_at": {
|
||||||
|
"name": "last_qso_sync_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"award_progress_user_id_users_id_fk": {
|
||||||
|
"name": "award_progress_user_id_users_id_fk",
|
||||||
|
"tableFrom": "award_progress",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"award_progress_award_id_awards_id_fk": {
|
||||||
|
"name": "award_progress_award_id_awards_id_fk",
|
||||||
|
"tableFrom": "award_progress",
|
||||||
|
"tableTo": "awards",
|
||||||
|
"columnsFrom": [
|
||||||
|
"award_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"awards": {
|
||||||
|
"name": "awards",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"definition": {
|
||||||
|
"name": "definition",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"qso_changes": {
|
||||||
|
"name": "qso_changes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"job_id": {
|
||||||
|
"name": "job_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"qso_id": {
|
||||||
|
"name": "qso_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"change_type": {
|
||||||
|
"name": "change_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before_data": {
|
||||||
|
"name": "before_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after_data": {
|
||||||
|
"name": "after_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"qso_changes_job_id_sync_jobs_id_fk": {
|
||||||
|
"name": "qso_changes_job_id_sync_jobs_id_fk",
|
||||||
|
"tableFrom": "qso_changes",
|
||||||
|
"tableTo": "sync_jobs",
|
||||||
|
"columnsFrom": [
|
||||||
|
"job_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"qso_changes_qso_id_qsos_id_fk": {
|
||||||
|
"name": "qso_changes_qso_id_qsos_id_fk",
|
||||||
|
"tableFrom": "qso_changes",
|
||||||
|
"tableTo": "qsos",
|
||||||
|
"columnsFrom": [
|
||||||
|
"qso_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"qsos": {
|
||||||
|
"name": "qsos",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"callsign": {
|
||||||
|
"name": "callsign",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"qso_date": {
|
||||||
|
"name": "qso_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"time_on": {
|
||||||
|
"name": "time_on",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"band": {
|
||||||
|
"name": "band",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"name": "mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"freq": {
|
||||||
|
"name": "freq",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"freq_rx": {
|
||||||
|
"name": "freq_rx",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"name": "entity",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "entity_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"name": "grid",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"grid_source": {
|
||||||
|
"name": "grid_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"continent": {
|
||||||
|
"name": "continent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cq_zone": {
|
||||||
|
"name": "cq_zone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"itu_zone": {
|
||||||
|
"name": "itu_zone",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"county": {
|
||||||
|
"name": "county",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sat_name": {
|
||||||
|
"name": "sat_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sat_mode": {
|
||||||
|
"name": "sat_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"my_darc_dok": {
|
||||||
|
"name": "my_darc_dok",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"darc_dok": {
|
||||||
|
"name": "darc_dok",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_qsl_rdate": {
|
||||||
|
"name": "lotw_qsl_rdate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_qsl_rstatus": {
|
||||||
|
"name": "lotw_qsl_rstatus",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_qsl_rdate": {
|
||||||
|
"name": "dcl_qsl_rdate",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_qsl_rstatus": {
|
||||||
|
"name": "dcl_qsl_rstatus",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_synced_at": {
|
||||||
|
"name": "lotw_synced_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"qsos_user_id_users_id_fk": {
|
||||||
|
"name": "qsos_user_id_users_id_fk",
|
||||||
|
"tableFrom": "qsos",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sync_jobs": {
|
||||||
|
"name": "sync_jobs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"name": "result",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sync_jobs_user_id_users_id_fk": {
|
||||||
|
"name": "sync_jobs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sync_jobs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"callsign": {
|
||||||
|
"name": "callsign",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_username": {
|
||||||
|
"name": "lotw_username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lotw_password": {
|
||||||
|
"name": "lotw_password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dcl_api_key": {
|
||||||
|
"name": "dcl_api_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_admin": {
|
||||||
|
"name": "is_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"when": 1768641501799,
|
"when": 1768641501799,
|
||||||
"tag": "0001_free_hiroim",
|
"tag": "0001_free_hiroim",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768988121232,
|
||||||
|
"tag": "0002_nervous_layla_miller",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768989260562,
|
||||||
|
"tag": "0003_tired_warpath",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,16 @@ const __dirname = dirname(__filename);
|
|||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
export const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
// SECURITY: Require JWT_SECRET in production - no fallback for security
|
||||||
|
// This prevents JWT token forgery if environment variable is not set
|
||||||
|
if (!process.env.JWT_SECRET && !isDevelopment) {
|
||||||
|
throw new Error(
|
||||||
|
'FATAL: JWT_SECRET environment variable must be set in production. ' +
|
||||||
|
'Generate one with: openssl rand -base64 32'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-in-production';
|
||||||
export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
|
export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -113,6 +122,8 @@ export const db = drizzle({
|
|||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { sqlite };
|
||||||
|
|
||||||
export async function closeDatabase() {
|
export async function closeDatabase() {
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|||||||
* @property {string|null} lotwUsername
|
* @property {string|null} lotwUsername
|
||||||
* @property {string|null} lotwPassword
|
* @property {string|null} lotwPassword
|
||||||
* @property {string|null} dclApiKey
|
* @property {string|null} dclApiKey
|
||||||
|
* @property {boolean} isAdmin
|
||||||
* @property {Date} createdAt
|
* @property {Date} createdAt
|
||||||
* @property {Date} updatedAt
|
* @property {Date} updatedAt
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +22,7 @@ export const users = sqliteTable('users', {
|
|||||||
lotwUsername: text('lotw_username'),
|
lotwUsername: text('lotw_username'),
|
||||||
lotwPassword: text('lotw_password'), // Encrypted
|
lotwPassword: text('lotw_password'), // Encrypted
|
||||||
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
dclApiKey: text('dcl_api_key'), // DCL API key for future use
|
||||||
|
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
@@ -202,5 +204,24 @@ export const qsoChanges = sqliteTable('qso_changes', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AdminAction
|
||||||
|
* @property {number} id
|
||||||
|
* @property {number} adminId
|
||||||
|
* @property {string} actionType
|
||||||
|
* @property {number|null} targetUserId
|
||||||
|
* @property {string|null} details
|
||||||
|
* @property {Date} createdAt
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const adminActions = sqliteTable('admin_actions', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
adminId: integer('admin_id').notNull().references(() => users.id),
|
||||||
|
actionType: text('action_type').notNull(), // 'impersonate_start', 'impersonate_stop', 'role_change', 'user_delete', etc.
|
||||||
|
targetUserId: integer('target_user_id').references(() => users.id),
|
||||||
|
details: text('details'), // JSON with additional context
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
// Export all schemas
|
// Export all schemas
|
||||||
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges };
|
export const schema = { users, qsos, awards, awardProgress, syncJobs, qsoChanges, adminActions };
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Elysia, t } from 'elysia';
|
import { Elysia, t } from 'elysia';
|
||||||
import { cors } from '@elysiajs/cors';
|
import { cors } from '@elysiajs/cors';
|
||||||
import { jwt } from '@elysiajs/jwt';
|
import { jwt } from '@elysiajs/jwt';
|
||||||
|
import { resolve, normalize } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
import { JWT_SECRET, logger, LOG_LEVEL, logToFrontend } from './config.js';
|
import { JWT_SECRET, logger, LOG_LEVEL, logToFrontend } from './config.js';
|
||||||
|
import { getPerformanceSummary, resetPerformanceMetrics } from './services/performance.service.js';
|
||||||
|
import { getCacheStats } from './services/cache.service.js';
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
@@ -9,6 +13,17 @@ import {
|
|||||||
updateLoTWCredentials,
|
updateLoTWCredentials,
|
||||||
updateDCLCredentials,
|
updateDCLCredentials,
|
||||||
} from './services/auth.service.js';
|
} from './services/auth.service.js';
|
||||||
|
import {
|
||||||
|
getSystemStats,
|
||||||
|
getUserStats,
|
||||||
|
impersonateUser,
|
||||||
|
verifyImpersonation,
|
||||||
|
stopImpersonation,
|
||||||
|
getImpersonationStatus,
|
||||||
|
changeUserRole,
|
||||||
|
deleteUser,
|
||||||
|
} from './services/admin.service.js';
|
||||||
|
import { getAllUsers } from './services/auth.service.js';
|
||||||
import {
|
import {
|
||||||
getUserQSOs,
|
getUserQSOs,
|
||||||
getQSOStats,
|
getQSOStats,
|
||||||
@@ -33,12 +48,116 @@ import {
|
|||||||
* Serves API routes and static frontend files
|
* Serves API routes and static frontend files
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Get allowed origins from environment or allow all in development
|
// SECURITY: Stricter email validation
|
||||||
|
// Elysia's built-in email format check is lenient; this provides better validation
|
||||||
|
// Allows: alphanumeric, dots, hyphens, underscores in local part
|
||||||
|
// Requires: valid domain with at least one dot
|
||||||
|
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
|
||||||
|
function isValidEmail(email) {
|
||||||
|
// Basic format check with regex
|
||||||
|
if (!EMAIL_REGEX.test(email)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional checks
|
||||||
|
const [local, domain] = email.split('@');
|
||||||
|
|
||||||
|
// Local part shouldn't start or end with dot
|
||||||
|
if (local.startsWith('.') || local.endsWith('.')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local part shouldn't have consecutive dots
|
||||||
|
if (local.includes('..')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain must have at least one dot and valid TLD
|
||||||
|
if (!domain || !domain.includes('.') || domain.split('.').pop().length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length checks (RFC 5321 limits)
|
||||||
|
if (email.length > 254 || local.length > 64) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Validate job ID parameter
|
||||||
|
// Prevents injection and DoS via extremely large numbers
|
||||||
|
function isValidJobId(jobId) {
|
||||||
|
const id = parseInt(jobId, 10);
|
||||||
|
// Check: must be a number, positive, and within reasonable range (1 to 2^31-1)
|
||||||
|
return !isNaN(id) && id > 0 && id <= 2147483647 && Number.isSafeInteger(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Validate QSO ID parameter
|
||||||
|
function isValidQsoId(qsoId) {
|
||||||
|
const id = parseInt(qsoId, 10);
|
||||||
|
return !isNaN(id) && id > 0 && id <= 2147483647 && Number.isSafeInteger(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: In-memory rate limiter for auth endpoints
|
||||||
|
// Prevents brute force attacks on login/register
|
||||||
|
const rateLimitStore = new Map(); // IP -> { count, resetTime }
|
||||||
|
|
||||||
|
function checkRateLimit(ip, limit = 5, windowMs = 60000) {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = rateLimitStore.get(ip);
|
||||||
|
|
||||||
|
// Clean up expired records
|
||||||
|
if (record && now > record.resetTime) {
|
||||||
|
rateLimitStore.delete(ip);
|
||||||
|
return { allowed: true, remaining: limit - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
if (record) {
|
||||||
|
if (record.count >= limit) {
|
||||||
|
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
|
||||||
|
return { allowed: false, retryAfter };
|
||||||
|
}
|
||||||
|
record.count++;
|
||||||
|
return { allowed: true, remaining: limit - record.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new record
|
||||||
|
rateLimitStore.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
resetTime: now + windowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { allowed: true, remaining: limit - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Validate that a path doesn't escape the allowed directory
|
||||||
|
// Prevents path traversal attacks like ../../../etc/passwd
|
||||||
|
const BUILD_DIR = resolve('src/frontend/build');
|
||||||
|
|
||||||
|
function isPathSafe(requestedPath) {
|
||||||
|
try {
|
||||||
|
// Normalize the path to resolve any ../ sequences
|
||||||
|
const normalized = normalize(requestedPath);
|
||||||
|
const fullPath = resolve(BUILD_DIR, normalized);
|
||||||
|
|
||||||
|
// Check if the resolved path is within the build directory
|
||||||
|
return fullPath.startsWith(BUILD_DIR);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get allowed origins from environment
|
||||||
|
// SECURITY: Never allow all origins, even in development
|
||||||
|
// Always require explicit ALLOWED_ORIGINS or VITE_APP_URL
|
||||||
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(',')
|
? process.env.ALLOWED_ORIGINS.split(',')
|
||||||
: process.env.NODE_ENV === 'production'
|
: process.env.NODE_ENV === 'production'
|
||||||
? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de']
|
? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de']
|
||||||
: true; // Allow all in development
|
: [process.env.VITE_APP_URL || 'http://localhost:5173']; // Default dev origin
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
// Enable CORS for frontend communication
|
// Enable CORS for frontend communication
|
||||||
@@ -68,18 +187,64 @@ const app = new Elysia()
|
|||||||
return { user: null };
|
return { user: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is an impersonation token
|
||||||
|
const isImpersonation = !!payload.impersonatedBy;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: payload.userId,
|
id: payload.userId,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
callsign: payload.callsign,
|
callsign: payload.callsign,
|
||||||
|
isAdmin: payload.isAdmin,
|
||||||
|
impersonatedBy: payload.impersonatedBy, // Admin ID if impersonating
|
||||||
},
|
},
|
||||||
|
isImpersonation,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { user: null };
|
return { user: null };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Security headers middleware
|
||||||
|
.onAfterHandle(({ set, request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// Prevent clickjacking
|
||||||
|
set.headers['X-Frame-Options'] = 'DENY';
|
||||||
|
|
||||||
|
// Prevent MIME type sniffing
|
||||||
|
set.headers['X-Content-Type-Options'] = 'nosniff';
|
||||||
|
|
||||||
|
// Enable XSS filter
|
||||||
|
set.headers['X-XSS-Protection'] = '1; mode=block';
|
||||||
|
|
||||||
|
// Referrer policy
|
||||||
|
set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
|
||||||
|
|
||||||
|
// Content Security Policy (basic protection)
|
||||||
|
// In development, allow any origin; in production, restrict
|
||||||
|
const appUrl = process.env.VITE_APP_URL || 'https://awards.dj7nt.de';
|
||||||
|
const connectSrc = isProduction ? `'self' ${appUrl}` : "'self' *";
|
||||||
|
|
||||||
|
set.headers['Content-Security-Policy'] = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self' 'unsafe-inline'`, // unsafe-inline needed for SvelteKit inline scripts
|
||||||
|
`style-src 'self' 'unsafe-inline'`, // unsafe-inline needed for SvelteKit inline styles
|
||||||
|
`img-src 'self' data: https:`,
|
||||||
|
`connect-src ${connectSrc}`,
|
||||||
|
"font-src 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
// Strict Transport Security (HTTPS only) - only in production
|
||||||
|
if (isProduction) {
|
||||||
|
set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Request logging middleware
|
// Request logging middleware
|
||||||
.onRequest(({ request, params }) => {
|
.onRequest(({ request, params }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -124,7 +289,31 @@ const app = new Elysia()
|
|||||||
*/
|
*/
|
||||||
.post(
|
.post(
|
||||||
'/api/auth/register',
|
'/api/auth/register',
|
||||||
async ({ body, jwt, set }) => {
|
async ({ body, jwt, set, request }) => {
|
||||||
|
// SECURITY: Rate limiting to prevent abuse
|
||||||
|
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||||
|
const rateLimit = checkRateLimit(ip, 5, 60000); // 5 requests per minute
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
set.status = 429;
|
||||||
|
set.headers['Retry-After'] = rateLimit.retryAfter.toString();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Too many registration attempts. Please try again later.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set.headers['X-RateLimit-Remaining'] = rateLimit.remaining.toString();
|
||||||
|
|
||||||
|
// SECURITY: Additional server-side email validation
|
||||||
|
if (!isValidEmail(body.email)) {
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid email address format',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await registerUser(body);
|
const user = await registerUser(body);
|
||||||
|
|
||||||
@@ -136,11 +325,13 @@ const app = new Elysia()
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token with expiration (24 hours)
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
|
||||||
const token = await jwt.sign({
|
const token = await jwt.sign({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
set.status = 201;
|
set.status = 201;
|
||||||
@@ -175,7 +366,22 @@ const app = new Elysia()
|
|||||||
*/
|
*/
|
||||||
.post(
|
.post(
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
async ({ body, jwt, set }) => {
|
async ({ body, jwt, set, request }) => {
|
||||||
|
// SECURITY: Rate limiting to prevent brute force attacks
|
||||||
|
const ip = request.headers.get('x-forwarded-for') || 'unknown';
|
||||||
|
const rateLimit = checkRateLimit(ip, 10, 60000); // 10 requests per minute (more lenient than register)
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
set.status = 429;
|
||||||
|
set.headers['Retry-After'] = rateLimit.retryAfter.toString();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Too many login attempts. Please try again later.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set.headers['X-RateLimit-Remaining'] = rateLimit.remaining.toString();
|
||||||
|
|
||||||
// Authenticate user
|
// Authenticate user
|
||||||
const user = await authenticateUser(body.email, body.password);
|
const user = await authenticateUser(body.email, body.password);
|
||||||
|
|
||||||
@@ -187,11 +393,14 @@ const app = new Elysia()
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token with expiration (24 hours)
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
|
||||||
const token = await jwt.sign({
|
const token = await jwt.sign({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
callsign: user.callsign,
|
callsign: user.callsign,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
exp,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -389,11 +598,12 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobId = parseInt(params.jobId);
|
// SECURITY: Validate job ID to prevent injection and DoS
|
||||||
if (isNaN(jobId)) {
|
if (!isValidJobId(params.jobId)) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'Invalid job ID' };
|
return { success: false, error: 'Invalid job ID' };
|
||||||
}
|
}
|
||||||
|
const jobId = parseInt(params.jobId, 10);
|
||||||
|
|
||||||
const job = await getJobStatus(jobId);
|
const job = await getJobStatus(jobId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@@ -498,11 +708,12 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobId = parseInt(params.jobId);
|
// SECURITY: Validate job ID to prevent injection and DoS
|
||||||
if (isNaN(jobId)) {
|
if (!isValidJobId(params.jobId)) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'Invalid job ID' };
|
return { success: false, error: 'Invalid job ID' };
|
||||||
}
|
}
|
||||||
|
const jobId = parseInt(params.jobId, 10);
|
||||||
|
|
||||||
const result = await cancelJob(jobId, user.id);
|
const result = await cancelJob(jobId, user.id);
|
||||||
|
|
||||||
@@ -576,11 +787,12 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qsoId = parseInt(params.id);
|
// SECURITY: Validate QSO ID to prevent injection and DoS
|
||||||
if (isNaN(qsoId)) {
|
if (!isValidQsoId(params.id)) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { success: false, error: 'Invalid QSO ID' };
|
return { success: false, error: 'Invalid QSO ID' };
|
||||||
}
|
}
|
||||||
|
const qsoId = parseInt(params.id, 10);
|
||||||
|
|
||||||
const qso = await getQSOById(user.id, qsoId);
|
const qso = await getQSOById(user.id, qsoId);
|
||||||
|
|
||||||
@@ -779,6 +991,9 @@ const app = new Elysia()
|
|||||||
.get('/api/health', () => ({
|
.get('/api/health', () => ({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
performance: getPerformanceSummary(),
|
||||||
|
cache: getCacheStats()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -822,6 +1037,359 @@ const app = new Elysia()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================================================
|
||||||
|
* ADMIN ROUTES
|
||||||
|
* ================================================================
|
||||||
|
* All admin routes require authentication and admin role
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/stats
|
||||||
|
* Get system-wide statistics (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/stats', async ({ user, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await getSystemStats();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching system stats', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch system statistics',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/users
|
||||||
|
* Get all users with statistics (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/users', async ({ user, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = await getUserStats();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
users,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching users', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch users',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/users/:userId
|
||||||
|
* Get detailed information about a specific user (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/users/:userId', async ({ user, params, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = parseInt(params.userId, 10);
|
||||||
|
if (isNaN(userId) || userId <= 0) {
|
||||||
|
set.status = 400;
|
||||||
|
return { success: false, error: 'Invalid user ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetUser = await getAllUsers();
|
||||||
|
const userDetails = targetUser.find(u => u.id === userId);
|
||||||
|
|
||||||
|
if (!userDetails) {
|
||||||
|
set.status = 404;
|
||||||
|
return { success: false, error: 'User not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: userDetails,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching user details', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch user details',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/users/:userId/role
|
||||||
|
* Update user admin status (admin only)
|
||||||
|
*/
|
||||||
|
.post('/api/admin/users/:userId/role', async ({ user, params, body, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = parseInt(params.userId, 10);
|
||||||
|
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||||
|
set.status = 400;
|
||||||
|
return { success: false, error: 'Invalid user ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isAdmin } = body;
|
||||||
|
|
||||||
|
if (typeof isAdmin !== 'boolean') {
|
||||||
|
set.status = 400;
|
||||||
|
return { success: false, error: 'isAdmin (boolean) is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changeUserRole(user.id, targetUserId, isAdmin);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'User admin status updated successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating user admin status', { error: error.message, userId: user.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/users/:userId
|
||||||
|
* Delete a user (admin only)
|
||||||
|
*/
|
||||||
|
.delete('/api/admin/users/:userId', async ({ user, params, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = parseInt(params.userId, 10);
|
||||||
|
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||||
|
set.status = 400;
|
||||||
|
return { success: false, error: 'Invalid user ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteUser(user.id, targetUserId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'User deleted successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting user', { error: error.message, userId: user.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/impersonate/:userId
|
||||||
|
* Start impersonating a user (admin only)
|
||||||
|
*/
|
||||||
|
.post('/api/admin/impersonate/:userId', async ({ user, params, jwt, set }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = parseInt(params.userId, 10);
|
||||||
|
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||||
|
set.status = 400;
|
||||||
|
return { success: false, error: 'Invalid user ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetUser = await impersonateUser(user.id, targetUserId);
|
||||||
|
|
||||||
|
// Generate impersonation token with shorter expiration (1 hour)
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + (60 * 60); // 1 hour from now
|
||||||
|
const token = await jwt.sign({
|
||||||
|
userId: targetUser.id,
|
||||||
|
email: targetUser.email,
|
||||||
|
callsign: targetUser.callsign,
|
||||||
|
isAdmin: targetUser.isAdmin,
|
||||||
|
impersonatedBy: user.id, // Admin ID who started impersonation
|
||||||
|
exp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
impersonating: {
|
||||||
|
userId: targetUser.id,
|
||||||
|
email: targetUser.email,
|
||||||
|
callsign: targetUser.callsign,
|
||||||
|
},
|
||||||
|
message: `Impersonating ${targetUser.email}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error starting impersonation', { error: error.message, userId: user.id });
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/impersonate/stop
|
||||||
|
* Stop impersonating and return to admin account (admin only)
|
||||||
|
*/
|
||||||
|
.post('/api/admin/impersonate/stop', async ({ user, jwt, body, set }) => {
|
||||||
|
if (!user || !user.impersonatedBy) {
|
||||||
|
set.status = 400;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Not currently impersonating a user',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Log impersonation stop
|
||||||
|
await stopImpersonation(user.impersonatedBy, user.id);
|
||||||
|
|
||||||
|
// Get admin user details to generate new token
|
||||||
|
const adminUsers = await getAllUsers();
|
||||||
|
const adminUser = adminUsers.find(u => u.id === user.impersonatedBy);
|
||||||
|
|
||||||
|
if (!adminUser) {
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Admin account not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new admin token (24 hours)
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60);
|
||||||
|
const token = await jwt.sign({
|
||||||
|
userId: adminUser.id,
|
||||||
|
email: adminUser.email,
|
||||||
|
callsign: adminUser.callsign,
|
||||||
|
isAdmin: adminUser.isAdmin,
|
||||||
|
exp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
token,
|
||||||
|
user: adminUser,
|
||||||
|
message: 'Impersonation stopped. Returned to admin account.',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error stopping impersonation', { error: error.message });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to stop impersonation',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/impersonation/status
|
||||||
|
* Get current impersonation status
|
||||||
|
*/
|
||||||
|
.get('/api/admin/impersonation/status', async ({ user }) => {
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
impersonating: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImpersonating = !!user.impersonatedBy;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
impersonating: isImpersonating,
|
||||||
|
impersonatedBy: user.impersonatedBy,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/actions
|
||||||
|
* Get admin actions log (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/actions', async ({ user, set, query }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(query.limit || '50', 10);
|
||||||
|
const offset = parseInt(query.offset || '0', 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const actions = await getAdminActions(null, { limit, offset });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching admin actions', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch admin actions',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/actions/my
|
||||||
|
* Get current admin's action log (admin only)
|
||||||
|
*/
|
||||||
|
.get('/api/admin/actions/my', async ({ user, set, query }) => {
|
||||||
|
if (!user || !user.isAdmin) {
|
||||||
|
set.status = !user ? 401 : 403;
|
||||||
|
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(query.limit || '50', 10);
|
||||||
|
const offset = parseInt(query.offset || '0', 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const actions = await getAdminActions(user.id, { limit, offset });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching admin actions', { error: error.message, userId: user.id });
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fetch admin actions',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -844,9 +1412,13 @@ const app = new Elysia()
|
|||||||
// Extract the actual file path after %sveltekit.assets%/
|
// Extract the actual file path after %sveltekit.assets%/
|
||||||
const assetPath = pathname.replace('/%sveltekit.assets%/', '');
|
const assetPath = pathname.replace('/%sveltekit.assets%/', '');
|
||||||
|
|
||||||
|
// SECURITY: Validate path before serving
|
||||||
|
const assetsDirPath = `_app/immutable/assets/${assetPath}`;
|
||||||
|
const rootPath = assetPath;
|
||||||
|
|
||||||
|
if (isPathSafe(assetsDirPath)) {
|
||||||
try {
|
try {
|
||||||
// Try to serve from assets directory first
|
const assetsPath = `src/frontend/build/${assetsDirPath}`;
|
||||||
const assetsPath = `src/frontend/build/_app/immutable/assets/${assetPath}`;
|
|
||||||
const file = Bun.file(assetsPath);
|
const file = Bun.file(assetsPath);
|
||||||
const exists = file.exists();
|
const exists = file.exists();
|
||||||
|
|
||||||
@@ -856,10 +1428,12 @@ const app = new Elysia()
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fall through to 404
|
// Fall through to 404
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If not in assets, try root directory
|
// If not in assets, try root directory (with path validation)
|
||||||
|
if (isPathSafe(rootPath)) {
|
||||||
try {
|
try {
|
||||||
const rootFile = Bun.file(`src/frontend/build/${assetPath}`);
|
const rootFile = Bun.file(`src/frontend/build/${rootPath}`);
|
||||||
const rootExists = rootFile.exists();
|
const rootExists = rootFile.exists();
|
||||||
if (rootExists) {
|
if (rootExists) {
|
||||||
return new Response(rootFile);
|
return new Response(rootFile);
|
||||||
@@ -867,6 +1441,7 @@ const app = new Elysia()
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fall through to 404
|
// Fall through to 404
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -875,6 +1450,12 @@ const app = new Elysia()
|
|||||||
// Remove leading slash for file path
|
// Remove leading slash for file path
|
||||||
const filePath = pathname === '/' ? '/index.html' : pathname;
|
const filePath = pathname === '/' ? '/index.html' : pathname;
|
||||||
|
|
||||||
|
// SECURITY: Validate path is within build directory before serving
|
||||||
|
if (!isPathSafe(filePath.substring(1))) { // Remove leading slash for relative path
|
||||||
|
logger.warn('Path traversal attempt blocked', { pathname });
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullPath = `src/frontend/build${filePath}`;
|
const fullPath = `src/frontend/build${filePath}`;
|
||||||
|
|
||||||
|
|||||||
103
src/backend/migrations/add-admin-functionality.js
Normal file
103
src/backend/migrations/add-admin-functionality.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add admin functionality to users table and create admin_actions table
|
||||||
|
*
|
||||||
|
* This script adds role-based access control (RBAC) for admin functionality:
|
||||||
|
* - Adds 'role' and 'isAdmin' columns to users table
|
||||||
|
* - Creates admin_actions table for audit logging
|
||||||
|
* - Adds indexes for performance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// ES module equivalent of __dirname
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const dbPath = join(__dirname, '../award.db');
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
console.log('Starting migration: Add admin functionality...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if role column already exists in users table
|
||||||
|
const columnExists = sqlite.query(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM pragma_table_info('users')
|
||||||
|
WHERE name = 'role'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (columnExists.count > 0) {
|
||||||
|
console.log('Admin columns already exist in users table. Skipping...');
|
||||||
|
} else {
|
||||||
|
// Add role column to users table
|
||||||
|
sqlite.exec(`
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN role TEXT NOT NULL DEFAULT 'user'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add isAdmin column to users table
|
||||||
|
sqlite.exec(`
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Added role and isAdmin columns to users table');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if admin_actions table already exists
|
||||||
|
const tableExists = sqlite.query(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='admin_actions'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (tableExists) {
|
||||||
|
console.log('Table admin_actions already exists. Skipping...');
|
||||||
|
} else {
|
||||||
|
// Create admin_actions table
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE admin_actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
admin_id INTEGER NOT NULL,
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
target_user_id INTEGER,
|
||||||
|
details TEXT,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
|
||||||
|
FOREIGN KEY (admin_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for admin_actions
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX idx_admin_actions_admin_id ON admin_actions(admin_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX idx_admin_actions_action_type ON admin_actions(action_type)
|
||||||
|
`);
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX idx_admin_actions_created_at ON admin_actions(created_at)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Created admin_actions table with indexes');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migration complete! Admin functionality added to database.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
sqlite.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration
|
||||||
|
migrate().then(() => {
|
||||||
|
console.log('Migration script completed successfully');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
* Migration: Add performance indexes for QSO queries
|
* Migration: Add performance indexes for QSO queries
|
||||||
*
|
*
|
||||||
* This script creates database indexes to significantly improve query performance
|
* This script creates database indexes to significantly improve query performance
|
||||||
* for filtering, sorting, and sync operations. Expected impact:
|
* for filtering, sorting, sync operations, and QSO statistics. Expected impact:
|
||||||
* - 80% faster filter queries
|
* - 80% faster filter queries
|
||||||
* - 60% faster sync operations
|
* - 60% faster sync operations
|
||||||
* - 50% faster award calculations
|
* - 50% faster award calculations
|
||||||
|
* - 95% faster QSO statistics queries (critical optimization)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Database from 'bun:sqlite';
|
import Database from 'bun:sqlite';
|
||||||
@@ -49,9 +50,21 @@ async function migrate() {
|
|||||||
console.log('Creating index: idx_qsos_qso_date');
|
console.log('Creating index: idx_qsos_qso_date');
|
||||||
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_qso_date ON qsos(user_id, qso_date DESC)`);
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_qso_date ON qsos(user_id, qso_date DESC)`);
|
||||||
|
|
||||||
|
// Index 8: QSO Statistics - Primary user filter (CRITICAL for getQSOStats)
|
||||||
|
console.log('Creating index: idx_qsos_user_primary');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_primary ON qsos(user_id)`);
|
||||||
|
|
||||||
|
// Index 9: QSO Statistics - Unique counts (entity, band, mode)
|
||||||
|
console.log('Creating index: idx_qsos_user_unique_counts');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_user_unique_counts ON qsos(user_id, entity, band, mode)`);
|
||||||
|
|
||||||
|
// Index 10: QSO Statistics - Optimized confirmation counting
|
||||||
|
console.log('Creating index: idx_qsos_stats_confirmation');
|
||||||
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_qsos_stats_confirmation ON qsos(user_id, lotw_qsl_rstatus, dcl_qsl_rstatus)`);
|
||||||
|
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
|
|
||||||
console.log('\nMigration complete! Created 7 performance indexes.');
|
console.log('\nMigration complete! Created 10 performance indexes.');
|
||||||
console.log('\nTo verify indexes were created, run:');
|
console.log('\nTo verify indexes were created, run:');
|
||||||
console.log(' sqlite3 award.db ".indexes qsos"');
|
console.log(' sqlite3 award.db ".indexes qsos"');
|
||||||
|
|
||||||
|
|||||||
251
src/backend/scripts/admin-cli.js
Normal file
251
src/backend/scripts/admin-cli.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Admin CLI Tool
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun src/backend/scripts/admin-cli.js create <email> <password> <callsign>
|
||||||
|
* bun src/backend/scripts/admin-cli.js promote <email>
|
||||||
|
* bun src/backend/scripts/admin-cli.js demote <email>
|
||||||
|
* bun src/backend/scripts/admin-cli.js list
|
||||||
|
* bun src/backend/scripts/admin-cli.js check <email>
|
||||||
|
* bun src/backend/scripts/admin-cli.js help
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// ES module equivalent of __dirname
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const dbPath = join(__dirname, '../award.db');
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
function help() {
|
||||||
|
console.log(`
|
||||||
|
Admin CLI Tool - Manage admin users
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
create <email> <password> <callsign> Create a new admin user
|
||||||
|
promote <email> Promote existing user to admin
|
||||||
|
demote <email> Demote admin to regular user
|
||||||
|
list List all admin users
|
||||||
|
check <email> Check if user is admin
|
||||||
|
help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bun src/backend/scripts/admin-cli.js create admin@example.com secretPassword ADMIN
|
||||||
|
bun src/backend/scripts/admin-cli.js promote user@example.com
|
||||||
|
bun src/backend/scripts/admin-cli.js list
|
||||||
|
bun src/backend/scripts/admin-cli.js check user@example.com
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdminUser(email, password, callsign) {
|
||||||
|
console.log(`Creating admin user: ${email}`);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = sqlite.query(`
|
||||||
|
SELECT id, email FROM users WHERE email = ?
|
||||||
|
`).get(email);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
console.error(`Error: User with email ${email} already exists`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = Bun.password.hashSync(password, {
|
||||||
|
algorithm: 'bcrypt',
|
||||||
|
cost: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure passwordHash is a string
|
||||||
|
const hashString = String(passwordHash);
|
||||||
|
|
||||||
|
// Insert admin user
|
||||||
|
const result = sqlite.query(`
|
||||||
|
INSERT INTO users (email, password_hash, callsign, is_admin, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, strftime('%s', 'now') * 1000, strftime('%s', 'now') * 1000)
|
||||||
|
`).run(email, hashString, callsign);
|
||||||
|
|
||||||
|
console.log(`✓ Admin user created successfully!`);
|
||||||
|
console.log(` ID: ${result.lastInsertRowid}`);
|
||||||
|
console.log(` Email: ${email}`);
|
||||||
|
console.log(` Callsign: ${callsign}`);
|
||||||
|
console.log(`\nYou can now log in with these credentials.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteUser(email) {
|
||||||
|
console.log(`Promoting user to admin: ${email}`);
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const user = sqlite.query(`
|
||||||
|
SELECT id, email, is_admin FROM users WHERE email = ?
|
||||||
|
`).get(email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`Error: User with email ${email} not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_admin === 1) {
|
||||||
|
console.log(`User ${email} is already an admin`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user to admin
|
||||||
|
sqlite.query(`
|
||||||
|
UPDATE users
|
||||||
|
SET is_admin = 1, updated_at = strftime('%s', 'now') * 1000
|
||||||
|
WHERE email = ?
|
||||||
|
`).run(email);
|
||||||
|
|
||||||
|
console.log(`✓ User ${email} has been promoted to admin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function demoteUser(email) {
|
||||||
|
console.log(`Demoting admin to regular user: ${email}`);
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const user = sqlite.query(`
|
||||||
|
SELECT id, email, is_admin FROM users WHERE email = ?
|
||||||
|
`).get(email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`Error: User with email ${email} not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_admin !== 1) {
|
||||||
|
console.log(`User ${email} is not an admin`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the last admin
|
||||||
|
const adminCount = sqlite.query(`
|
||||||
|
SELECT COUNT(*) as count FROM users WHERE is_admin = 1
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (adminCount.count === 1) {
|
||||||
|
console.error(`Error: Cannot demote the last admin user. At least one admin must exist.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user to regular user
|
||||||
|
sqlite.query(`
|
||||||
|
UPDATE users
|
||||||
|
SET is_admin = 0, updated_at = strftime('%s', 'now') * 1000
|
||||||
|
WHERE email = ?
|
||||||
|
`).run(email);
|
||||||
|
|
||||||
|
console.log(`✓ User ${email} has been demoted to regular user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAdmins() {
|
||||||
|
console.log('Listing all admin users...\n');
|
||||||
|
|
||||||
|
const admins = sqlite.query(`
|
||||||
|
SELECT id, email, callsign, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE is_admin = 1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
if (admins.length === 0) {
|
||||||
|
console.log('No admin users found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${admins.length} admin user(s):\n`);
|
||||||
|
console.log('ID | Email | Callsign | Created At');
|
||||||
|
console.log('----+----------------------------+----------+---------------------');
|
||||||
|
|
||||||
|
admins.forEach((admin) => {
|
||||||
|
const createdAt = new Date(admin.created_at).toLocaleString();
|
||||||
|
console.log(`${String(admin.id).padEnd(3)} | ${admin.email.padEnd(26)} | ${admin.callsign.padEnd(8)} | ${createdAt}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUser(email) {
|
||||||
|
console.log(`Checking user status: ${email}\n`);
|
||||||
|
|
||||||
|
const user = sqlite.query(`
|
||||||
|
SELECT id, email, callsign, is_admin FROM users WHERE email = ?
|
||||||
|
`).get(email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log(`User not found: ${email}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user.is_admin === 1;
|
||||||
|
|
||||||
|
console.log(`User found:`);
|
||||||
|
console.log(` Email: ${user.email}`);
|
||||||
|
console.log(` Callsign: ${user.callsign}`);
|
||||||
|
console.log(` Is Admin: ${isAdmin ? 'Yes ✓' : 'No'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main CLI logic
|
||||||
|
const command = process.argv[2];
|
||||||
|
const args = process.argv.slice(3);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'create':
|
||||||
|
if (args.length !== 3) {
|
||||||
|
console.error('Error: create command requires 3 arguments: <email> <password> <callsign>');
|
||||||
|
help();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
createAdminUser(args[0], args[1], args[2]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'promote':
|
||||||
|
if (args.length !== 1) {
|
||||||
|
console.error('Error: promote command requires 1 argument: <email>');
|
||||||
|
help();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
promoteUser(args[0]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'demote':
|
||||||
|
if (args.length !== 1) {
|
||||||
|
console.error('Error: demote command requires 1 argument: <email>');
|
||||||
|
help();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
demoteUser(args[0]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
listAdmins();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'check':
|
||||||
|
if (args.length !== 1) {
|
||||||
|
console.error('Error: check command requires 1 argument: <email>');
|
||||||
|
help();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
checkUser(args[0]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
help();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Error: Unknown command '${command}'`);
|
||||||
|
help();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
387
src/backend/services/admin.service.js
Normal file
387
src/backend/services/admin.service.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
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 } from './auth.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an admin action for audit trail
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {string} actionType - Type of action (e.g., 'impersonate_start', 'role_change')
|
||||||
|
* @param {number|null} targetUserId - Target user ID (if applicable)
|
||||||
|
* @param {Object} details - Additional details (will be JSON stringified)
|
||||||
|
* @returns {Promise<Object>} Created admin action record
|
||||||
|
*/
|
||||||
|
export async function logAdminAction(adminId, actionType, targetUserId = null, details = {}) {
|
||||||
|
const [action] = await db
|
||||||
|
.insert(adminActions)
|
||||||
|
.values({
|
||||||
|
adminId,
|
||||||
|
actionType,
|
||||||
|
targetUserId,
|
||||||
|
details: JSON.stringify(details),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get admin actions log
|
||||||
|
* @param {number} adminId - Admin user ID (optional, if null returns all actions)
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {number} options.limit - Number of records to return
|
||||||
|
* @param {number} options.offset - Number of records to skip
|
||||||
|
* @returns {Promise<Array>} Array of admin actions
|
||||||
|
*/
|
||||||
|
export async function getAdminActions(adminId = null, { limit = 50, offset = 0 } = {}) {
|
||||||
|
let query = db
|
||||||
|
.select({
|
||||||
|
id: adminActions.id,
|
||||||
|
adminId: adminActions.adminId,
|
||||||
|
adminEmail: users.email,
|
||||||
|
adminCallsign: users.callsign,
|
||||||
|
actionType: adminActions.actionType,
|
||||||
|
targetUserId: adminActions.targetUserId,
|
||||||
|
targetEmail: sql`target_users.email`.as('targetEmail'),
|
||||||
|
targetCallsign: sql`target_users.callsign`.as('targetCallsign'),
|
||||||
|
details: adminActions.details,
|
||||||
|
createdAt: adminActions.createdAt,
|
||||||
|
})
|
||||||
|
.from(adminActions)
|
||||||
|
.leftJoin(users, eq(adminActions.adminId, users.id))
|
||||||
|
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
||||||
|
.orderBy(desc(adminActions.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
if (adminId) {
|
||||||
|
query = query.where(eq(adminActions.adminId, adminId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system-wide statistics
|
||||||
|
* @returns {Promise<Object>} System statistics
|
||||||
|
*/
|
||||||
|
export async function getSystemStats() {
|
||||||
|
const [
|
||||||
|
userStats,
|
||||||
|
qsoStats,
|
||||||
|
syncJobStats,
|
||||||
|
adminStats,
|
||||||
|
] = await Promise.all([
|
||||||
|
// User statistics
|
||||||
|
db.select({
|
||||||
|
totalUsers: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
adminUsers: sql`CAST(SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
regularUsers: sql`CAST(SUM(CASE WHEN is_admin = 0 THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(users),
|
||||||
|
|
||||||
|
// QSO statistics
|
||||||
|
db.select({
|
||||||
|
totalQSOs: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
uniqueCallsigns: sql`CAST(COUNT(DISTINCT callsign) AS INTEGER)`,
|
||||||
|
uniqueEntities: sql`CAST(COUNT(DISTINCT entity_id) AS INTEGER)`,
|
||||||
|
lotwConfirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
dclConfirmed: sql`CAST(SUM(CASE WHEN dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(qsos),
|
||||||
|
|
||||||
|
// Sync job statistics
|
||||||
|
db.select({
|
||||||
|
totalJobs: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
lotwJobs: sql`CAST(SUM(CASE WHEN type = 'lotw_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
dclJobs: sql`CAST(SUM(CASE WHEN type = 'dcl_sync' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
completedJobs: sql`CAST(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
failedJobs: sql`CAST(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(syncJobs),
|
||||||
|
|
||||||
|
// Admin action statistics
|
||||||
|
db.select({
|
||||||
|
totalAdminActions: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
impersonations: sql`CAST(SUM(CASE WHEN action_type LIKE 'impersonate%' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
}).from(adminActions),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: userStats[0],
|
||||||
|
qsos: qsoStats[0],
|
||||||
|
syncJobs: syncJobStats[0],
|
||||||
|
adminActions: adminStats[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get per-user statistics (for admin overview)
|
||||||
|
* @returns {Promise<Array>} Array of user statistics
|
||||||
|
*/
|
||||||
|
export async function getUserStats() {
|
||||||
|
const stats = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
callsign: users.callsign,
|
||||||
|
isAdmin: users.isAdmin,
|
||||||
|
qsoCount: sql`CAST(COUNT(${qsos.id}) AS INTEGER)`,
|
||||||
|
lotwConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
dclConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
totalConfirmed: sql`CAST(SUM(CASE WHEN ${qsos.lotwQslRstatus} = 'Y' OR ${qsos.dclQslRstatus} = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`,
|
||||||
|
lastSync: sql`MAX(${qsos.createdAt})`,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(qsos, eq(users.id, qsos.userId))
|
||||||
|
.groupBy(users.id)
|
||||||
|
.orderBy(sql`COUNT(${qsos.id}) DESC`);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impersonate a user
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {number} targetUserId - Target user ID to impersonate
|
||||||
|
* @returns {Promise<Object>} Target user object
|
||||||
|
* @throws {Error} If not admin or trying to impersonate another admin
|
||||||
|
*/
|
||||||
|
export async function impersonateUser(adminId, targetUserId) {
|
||||||
|
// Verify the requester is an admin
|
||||||
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
|
if (!requesterIsAdmin) {
|
||||||
|
throw new Error('Only admins can impersonate users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target is also an admin (prevent admin impersonation)
|
||||||
|
if (targetUser.isAdmin) {
|
||||||
|
throw new Error('Cannot impersonate another admin user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log impersonation action
|
||||||
|
await logAdminAction(adminId, 'impersonate_start', targetUserId, {
|
||||||
|
targetEmail: targetUser.email,
|
||||||
|
targetCallsign: targetUser.callsign,
|
||||||
|
});
|
||||||
|
|
||||||
|
return targetUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify impersonation token is valid
|
||||||
|
* @param {Object} impersonationToken - JWT token payload containing impersonation data
|
||||||
|
* @returns {Promise<Object>} Verification result with target user data
|
||||||
|
*/
|
||||||
|
export async function verifyImpersonation(impersonationToken) {
|
||||||
|
const { adminId, targetUserId, exp } = impersonationToken;
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
if (Date.now() > exp * 1000) {
|
||||||
|
throw new Error('Impersonation token has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify admin still exists and is admin
|
||||||
|
const adminUser = await getUserByIdFull(adminId);
|
||||||
|
if (!adminUser || !adminUser.isAdmin) {
|
||||||
|
throw new Error('Invalid impersonation: Admin no longer exists or is not admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return target user with admin metadata for frontend display
|
||||||
|
return {
|
||||||
|
...targetUser,
|
||||||
|
impersonating: {
|
||||||
|
adminId,
|
||||||
|
adminEmail: adminUser.email,
|
||||||
|
adminCallsign: adminUser.callsign,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop impersonating a user
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {number} targetUserId - Target user ID being impersonated
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function stopImpersonation(adminId, targetUserId) {
|
||||||
|
await logAdminAction(adminId, 'impersonate_stop', targetUserId, {
|
||||||
|
message: 'Impersonation session ended',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get impersonation status for an admin
|
||||||
|
* @param {number} adminId - Admin user ID
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {number} options.limit - Number of recent impersonations to return
|
||||||
|
* @returns {Promise<Array>} Array of recent impersonation actions
|
||||||
|
*/
|
||||||
|
export async function getImpersonationStatus(adminId, { limit = 10 } = {}) {
|
||||||
|
const impersonations = await db
|
||||||
|
.select({
|
||||||
|
id: adminActions.id,
|
||||||
|
actionType: adminActions.actionType,
|
||||||
|
targetUserId: adminActions.targetUserId,
|
||||||
|
targetEmail: sql`target_users.email`,
|
||||||
|
targetCallsign: sql`target_users.callsign`,
|
||||||
|
details: adminActions.details,
|
||||||
|
createdAt: adminActions.createdAt,
|
||||||
|
})
|
||||||
|
.from(adminActions)
|
||||||
|
.leftJoin(sql`${users} as target_users`, eq(adminActions.targetUserId, sql.raw('target_users.id')))
|
||||||
|
.where(eq(adminActions.adminId, adminId))
|
||||||
|
.where(sql`${adminActions.actionType} LIKE 'impersonate%'`)
|
||||||
|
.orderBy(desc(adminActions.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return impersonations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user admin status (admin operation)
|
||||||
|
* @param {number} adminId - Admin user ID making the change
|
||||||
|
* @param {number} targetUserId - User ID to update
|
||||||
|
* @param {boolean} newIsAdmin - New admin flag
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws {Error} If not admin or would remove last admin
|
||||||
|
*/
|
||||||
|
export async function changeUserRole(adminId, targetUserId, newIsAdmin) {
|
||||||
|
// Verify the requester is an admin
|
||||||
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
|
if (!requesterIsAdmin) {
|
||||||
|
throw new Error('Only admins can change user admin status');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If demoting from admin, check if this would remove the last admin
|
||||||
|
if (targetUser.isAdmin && !newIsAdmin) {
|
||||||
|
const adminCount = await db
|
||||||
|
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.isAdmin, 1));
|
||||||
|
|
||||||
|
if (adminCount[0].count === 1) {
|
||||||
|
throw new Error('Cannot demote the last admin user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update admin status
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
isAdmin: newIsAdmin ? 1 : 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, targetUserId));
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await logAdminAction(adminId, 'role_change', targetUserId, {
|
||||||
|
oldIsAdmin: targetUser.isAdmin,
|
||||||
|
newIsAdmin: newIsAdmin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user (admin operation)
|
||||||
|
* @param {number} adminId - Admin user ID making the change
|
||||||
|
* @param {number} targetUserId - User ID to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws {Error} If not admin, trying to delete self, or trying to delete another admin
|
||||||
|
*/
|
||||||
|
export async function deleteUser(adminId, targetUserId) {
|
||||||
|
// Verify the requester is an admin
|
||||||
|
const requesterIsAdmin = await isAdmin(adminId);
|
||||||
|
if (!requesterIsAdmin) {
|
||||||
|
throw new Error('Only admins can delete users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await getUserByIdFull(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error('Target user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting self
|
||||||
|
if (adminId === targetUserId) {
|
||||||
|
throw new Error('Cannot delete your own account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting other admins
|
||||||
|
if (targetUser.isAdmin) {
|
||||||
|
throw new Error('Cannot delete admin users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats for logging
|
||||||
|
const [qsoStats] = await db
|
||||||
|
.select({ count: sql`CAST(COUNT(*) AS INTEGER)` })
|
||||||
|
.from(qsos)
|
||||||
|
.where(eq(qsos.userId, targetUserId));
|
||||||
|
|
||||||
|
// Delete all related records using Drizzle
|
||||||
|
// Delete in correct order to satisfy foreign key constraints
|
||||||
|
logger.info('Attempting to delete user', { userId: targetUserId, adminId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Delete qso_changes (references qso_id -> qsos and job_id -> sync_jobs)
|
||||||
|
// First get user's QSO IDs, then delete qso_changes referencing those QSOs
|
||||||
|
const userQSOs = await db.select({ id: qsos.id }).from(qsos).where(eq(qsos.userId, targetUserId));
|
||||||
|
const userQSOIds = userQSOs.map(q => q.id);
|
||||||
|
|
||||||
|
if (userQSOIds.length > 0) {
|
||||||
|
// Use raw SQL to delete qso_changes
|
||||||
|
sqlite.exec(
|
||||||
|
`DELETE FROM qso_changes WHERE qso_id IN (${userQSOIds.join(',')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete award_progress
|
||||||
|
await db.delete(awardProgress).where(eq(awardProgress.userId, targetUserId));
|
||||||
|
|
||||||
|
// 3. Delete sync_jobs
|
||||||
|
await db.delete(syncJobs).where(eq(syncJobs.userId, targetUserId));
|
||||||
|
|
||||||
|
// 4. Delete qsos
|
||||||
|
await db.delete(qsos).where(eq(qsos.userId, targetUserId));
|
||||||
|
|
||||||
|
// 5. Delete admin actions where user is target
|
||||||
|
await db.delete(adminActions).where(eq(adminActions.targetUserId, targetUserId));
|
||||||
|
|
||||||
|
// 6. Delete user
|
||||||
|
await db.delete(users).where(eq(users.id, targetUserId));
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await logAdminAction(adminId, 'user_delete', targetUserId, {
|
||||||
|
email: targetUser.email,
|
||||||
|
callsign: targetUser.callsign,
|
||||||
|
qsoCountDeleted: qsoStats.count,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('User deleted successfully', { userId: targetUserId, adminId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete user', { error: error.message, userId: targetUserId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await logAdminAction(adminId, 'user_delete', targetUserId, {
|
||||||
|
email: targetUser.email,
|
||||||
|
callsign: targetUser.callsign,
|
||||||
|
qsoCountDeleted: qsoStats.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -142,3 +142,97 @@ export async function updateDCLCredentials(userId, dclApiKey) {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {Promise<boolean>} True if user is admin
|
||||||
|
*/
|
||||||
|
export async function isAdmin(userId) {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ isAdmin: users.isAdmin })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return user?.isAdmin === true || user?.isAdmin === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all admin users
|
||||||
|
* @returns {Promise<Array>} Array of admin users (without passwords)
|
||||||
|
*/
|
||||||
|
export async function getAdminUsers() {
|
||||||
|
const adminUsers = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
callsign: users.callsign,
|
||||||
|
isAdmin: users.isAdmin,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.isAdmin, 1));
|
||||||
|
|
||||||
|
return adminUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user admin status
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {boolean} isAdmin - Admin flag
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function updateUserRole(userId, isAdmin) {
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
isAdmin: isAdmin ? 1 : 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users (for admin use)
|
||||||
|
* @returns {Promise<Array>} Array of all users (without passwords)
|
||||||
|
*/
|
||||||
|
export async function getAllUsers() {
|
||||||
|
const allUsers = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
callsign: users.callsign,
|
||||||
|
isAdmin: users.isAdmin,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
updatedAt: users.updatedAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.orderBy(users.createdAt);
|
||||||
|
|
||||||
|
return allUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID (for admin use)
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {Promise<Object|null>} Full user object (without password) or null
|
||||||
|
*/
|
||||||
|
export async function getUserByIdFull(userId) {
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
callsign: users.callsign,
|
||||||
|
isAdmin: users.isAdmin,
|
||||||
|
lotwUsername: users.lotwUsername,
|
||||||
|
dclApiKey: users.dclApiKey,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
updatedAt: users.updatedAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function loadAwardDefinitions() {
|
|||||||
'dld-40m.json',
|
'dld-40m.json',
|
||||||
'dld-cw.json',
|
'dld-cw.json',
|
||||||
'dld-80m-cw.json',
|
'dld-80m-cw.json',
|
||||||
|
'73-on-73.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const awardCache = new Map();
|
const awardCache = new Map();
|
||||||
|
const statsCache = new Map();
|
||||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +27,7 @@ export function getCachedAwardProgress(userId, awardId) {
|
|||||||
const cached = awardCache.get(key);
|
const cached = awardCache.get(key);
|
||||||
|
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
|
recordAwardCacheMiss();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +35,11 @@ export function getCachedAwardProgress(userId, awardId) {
|
|||||||
const age = Date.now() - cached.timestamp;
|
const age = Date.now() - cached.timestamp;
|
||||||
if (age > CACHE_TTL) {
|
if (age > CACHE_TTL) {
|
||||||
awardCache.delete(key);
|
awardCache.delete(key);
|
||||||
|
recordAwardCacheMiss();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAwardCacheHit();
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,5 +129,147 @@ export function cleanupExpiredCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of statsCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
statsCache.delete(key);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached QSO statistics if available and not expired
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {object|null} Cached stats data or null if not found/expired
|
||||||
|
*/
|
||||||
|
export function getCachedStats(userId) {
|
||||||
|
const key = `stats_${userId}`;
|
||||||
|
const cached = statsCache.get(key);
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
recordStatsCacheMiss();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache has expired
|
||||||
|
const age = Date.now() - cached.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
statsCache.delete(key);
|
||||||
|
recordStatsCacheMiss();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordStatsCacheHit();
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set QSO statistics in cache
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {object} data - Statistics data to cache
|
||||||
|
*/
|
||||||
|
export function setCachedStats(userId, data) {
|
||||||
|
const key = `stats_${userId}`;
|
||||||
|
statsCache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cached QSO statistics for a specific user
|
||||||
|
* Call this after syncing or updating QSOs
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {boolean} True if cache was invalidated
|
||||||
|
*/
|
||||||
|
export function invalidateStatsCache(userId) {
|
||||||
|
const key = `stats_${userId}`;
|
||||||
|
const deleted = statsCache.delete(key);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics including both award and stats caches
|
||||||
|
* @returns {object} Cache stats
|
||||||
|
*/
|
||||||
|
export function getCacheStats() {
|
||||||
|
const now = Date.now();
|
||||||
|
let expired = 0;
|
||||||
|
let valid = 0;
|
||||||
|
|
||||||
|
for (const [, value] of awardCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
expired++;
|
||||||
|
} else {
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, value] of statsCache) {
|
||||||
|
const age = now - value.timestamp;
|
||||||
|
if (age > CACHE_TTL) {
|
||||||
|
expired++;
|
||||||
|
} else {
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = awardCacheStats.hits + awardCacheStats.misses + statsCacheStats.hits + statsCacheStats.misses;
|
||||||
|
const hitRate = totalRequests > 0 ? ((awardCacheStats.hits + statsCacheStats.hits) / totalRequests * 100).toFixed(2) + '%' : '0%';
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: awardCache.size + statsCache.size,
|
||||||
|
valid,
|
||||||
|
expired,
|
||||||
|
ttl: CACHE_TTL,
|
||||||
|
hitRate,
|
||||||
|
awardCache: {
|
||||||
|
size: awardCache.size,
|
||||||
|
hits: awardCacheStats.hits,
|
||||||
|
misses: awardCacheStats.misses
|
||||||
|
},
|
||||||
|
statsCache: {
|
||||||
|
size: statsCache.size,
|
||||||
|
hits: statsCacheStats.hits,
|
||||||
|
misses: statsCacheStats.misses
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache statistics tracking
|
||||||
|
*/
|
||||||
|
const awardCacheStats = { hits: 0, misses: 0 };
|
||||||
|
const statsCacheStats = { hits: 0, misses: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a cache hit for awards
|
||||||
|
*/
|
||||||
|
export function recordAwardCacheHit() {
|
||||||
|
awardCacheStats.hits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a cache miss for awards
|
||||||
|
*/
|
||||||
|
export function recordAwardCacheMiss() {
|
||||||
|
awardCacheStats.misses++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a cache hit for stats
|
||||||
|
*/
|
||||||
|
export function recordStatsCacheHit() {
|
||||||
|
statsCacheStats.hits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a cache miss for stats
|
||||||
|
*/
|
||||||
|
export function recordStatsCacheMiss() {
|
||||||
|
statsCacheStats.misses++;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { qsos, qsoChanges } from '../db/schema/index.js';
|
|||||||
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
||||||
import { updateJobProgress } from './job-queue.service.js';
|
import { updateJobProgress } from './job-queue.service.js';
|
||||||
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
import { parseDCLResponse, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
||||||
import { invalidateUserCache } from './cache.service.js';
|
import { invalidateUserCache, invalidateStatsCache } from './cache.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DCL (DARC Community Logbook) Service
|
* DCL (DARC Community Logbook) Service
|
||||||
@@ -170,7 +170,22 @@ function convertQSODatabaseFormat(adifQSO, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync QSOs from DCL to database
|
* 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)
|
||||||
* Updates existing QSOs with DCL confirmation data
|
* Updates existing QSOs with DCL confirmation data
|
||||||
*
|
*
|
||||||
* @param {number} userId - User ID
|
* @param {number} userId - User ID
|
||||||
@@ -219,31 +234,52 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
const addedQSOs = [];
|
const addedQSOs = [];
|
||||||
const updatedQSOs = [];
|
const updatedQSOs = [];
|
||||||
|
|
||||||
for (let i = 0; i < adifQSOs.length; i++) {
|
// Convert all QSOs to database format
|
||||||
const adifQSO = adifQSOs[i];
|
const dbQSOs = adifQSOs.map(qso => convertQSODatabaseFormat(qso, userId));
|
||||||
|
|
||||||
try {
|
// Batch size for processing
|
||||||
const dbQSO = convertQSODatabaseFormat(adifQSO, userId);
|
const BATCH_SIZE = 100;
|
||||||
|
const totalBatches = Math.ceil(dbQSOs.length / BATCH_SIZE);
|
||||||
|
|
||||||
// Check if QSO already exists (match by callsign, date, time, band, mode)
|
for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||||
const existing = await db
|
const startIdx = batchNum * BATCH_SIZE;
|
||||||
|
const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
|
||||||
|
const batch = dbQSOs.slice(startIdx, endIdx);
|
||||||
|
|
||||||
|
// Get unique callsigns and dates from batch
|
||||||
|
const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
|
||||||
|
const batchDates = [...new Set(batch.map(q => q.qsoDate))];
|
||||||
|
|
||||||
|
// Fetch all existing QSOs that could match this batch in one query
|
||||||
|
const existingQSOs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(qsos)
|
.from(qsos)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(qsos.userId, userId),
|
eq(qsos.userId, userId),
|
||||||
eq(qsos.callsign, dbQSO.callsign),
|
// Match callsigns OR dates from this batch
|
||||||
eq(qsos.qsoDate, dbQSO.qsoDate),
|
sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
|
||||||
eq(qsos.timeOn, dbQSO.timeOn),
|
|
||||||
eq(qsos.band, dbQSO.band),
|
|
||||||
eq(qsos.mode, dbQSO.mode)
|
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
// Build lookup map for existing QSOs
|
||||||
const existingQSO = existing[0];
|
const existingMap = new Map();
|
||||||
|
for (const existing of existingQSOs) {
|
||||||
|
const key = getQSOKey(existing);
|
||||||
|
existingMap.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const toInsert = [];
|
||||||
|
const toUpdate = [];
|
||||||
|
const changeRecords = [];
|
||||||
|
|
||||||
|
for (const dbQSO of batch) {
|
||||||
|
try {
|
||||||
|
const key = getQSOKey(dbQSO);
|
||||||
|
const existingQSO = existingMap.get(key);
|
||||||
|
|
||||||
|
if (existingQSO) {
|
||||||
// Check if DCL confirmation or DOK data has changed
|
// Check if DCL confirmation or DOK data has changed
|
||||||
const dataChanged =
|
const dataChanged =
|
||||||
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
|
existingQSO.dclQslRstatus !== dbQSO.dclQslRstatus ||
|
||||||
@@ -253,19 +289,7 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
|
existingQSO.grid !== (dbQSO.grid || existingQSO.grid);
|
||||||
|
|
||||||
if (dataChanged) {
|
if (dataChanged) {
|
||||||
// Record before state for rollback
|
// Build update data
|
||||||
const beforeData = JSON.stringify({
|
|
||||||
dclQslRstatus: existingQSO.dclQslRstatus,
|
|
||||||
dclQslRdate: existingQSO.dclQslRdate,
|
|
||||||
darcDok: existingQSO.darcDok,
|
|
||||||
myDarcDok: existingQSO.myDarcDok,
|
|
||||||
grid: existingQSO.grid,
|
|
||||||
gridSource: existingQSO.gridSource,
|
|
||||||
entity: existingQSO.entity,
|
|
||||||
entityId: existingQSO.entityId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update existing QSO with changed DCL confirmation and DOK data
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
dclQslRdate: dbQSO.dclQslRdate,
|
dclQslRdate: dbQSO.dclQslRdate,
|
||||||
dclQslRstatus: dbQSO.dclQslRstatus,
|
dclQslRstatus: dbQSO.dclQslRstatus,
|
||||||
@@ -291,7 +315,6 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
|
const missingEntity = !existingQSO.entity || existingQSO.entity === '';
|
||||||
|
|
||||||
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
if (!hasLoTWConfirmation && hasDCLData && missingEntity) {
|
||||||
// Fill in entity data from DCL (only if DCL provides it)
|
|
||||||
if (dbQSO.entity) updateData.entity = dbQSO.entity;
|
if (dbQSO.entity) updateData.entity = dbQSO.entity;
|
||||||
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
|
if (dbQSO.entityId) updateData.entityId = dbQSO.entityId;
|
||||||
if (dbQSO.continent) updateData.continent = dbQSO.continent;
|
if (dbQSO.continent) updateData.continent = dbQSO.continent;
|
||||||
@@ -299,13 +322,28 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
|
if (dbQSO.ituZone) updateData.ituZone = dbQSO.ituZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
toUpdate.push({
|
||||||
.update(qsos)
|
id: existingQSO.id,
|
||||||
.set(updateData)
|
data: updateData,
|
||||||
.where(eq(qsos.id, existingQSO.id));
|
});
|
||||||
|
|
||||||
// Record after state for rollback
|
// Track change for rollback
|
||||||
const afterData = JSON.stringify({
|
if (jobId) {
|
||||||
|
changeRecords.push({
|
||||||
|
jobId,
|
||||||
|
qsoId: existingQSO.id,
|
||||||
|
changeType: 'updated',
|
||||||
|
beforeData: JSON.stringify({
|
||||||
|
dclQslRstatus: existingQSO.dclQslRstatus,
|
||||||
|
dclQslRdate: existingQSO.dclQslRdate,
|
||||||
|
darcDok: existingQSO.darcDok,
|
||||||
|
myDarcDok: existingQSO.myDarcDok,
|
||||||
|
grid: existingQSO.grid,
|
||||||
|
gridSource: existingQSO.gridSource,
|
||||||
|
entity: existingQSO.entity,
|
||||||
|
entityId: existingQSO.entityId,
|
||||||
|
}),
|
||||||
|
afterData: JSON.stringify({
|
||||||
dclQslRstatus: dbQSO.dclQslRstatus,
|
dclQslRstatus: dbQSO.dclQslRstatus,
|
||||||
dclQslRdate: dbQSO.dclQslRdate,
|
dclQslRdate: dbQSO.dclQslRdate,
|
||||||
darcDok: updateData.darcDok,
|
darcDok: updateData.darcDok,
|
||||||
@@ -314,21 +352,10 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
gridSource: updateData.gridSource,
|
gridSource: updateData.gridSource,
|
||||||
entity: updateData.entity,
|
entity: updateData.entity,
|
||||||
entityId: updateData.entityId,
|
entityId: updateData.entityId,
|
||||||
});
|
}),
|
||||||
|
|
||||||
// Track change in qso_changes table if jobId provided
|
|
||||||
if (jobId) {
|
|
||||||
await db.insert(qsoChanges).values({
|
|
||||||
jobId,
|
|
||||||
qsoId: existingQSO.id,
|
|
||||||
changeType: 'updated',
|
|
||||||
beforeData,
|
|
||||||
afterData,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
// Track updated QSO (CALL and DATE)
|
|
||||||
updatedQSOs.push({
|
updatedQSOs.push({
|
||||||
id: existingQSO.id,
|
id: existingQSO.id,
|
||||||
callsign: dbQSO.callsign,
|
callsign: dbQSO.callsign,
|
||||||
@@ -336,64 +363,86 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
band: dbQSO.band,
|
band: dbQSO.band,
|
||||||
mode: dbQSO.mode,
|
mode: dbQSO.mode,
|
||||||
});
|
});
|
||||||
|
updatedCount++;
|
||||||
} else {
|
} else {
|
||||||
// Skip - same data
|
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert new QSO
|
// New QSO to insert
|
||||||
const [newQSO] = await db.insert(qsos).values(dbQSO).returning();
|
toInsert.push(dbQSO);
|
||||||
|
|
||||||
// Track change in qso_changes table if jobId provided
|
|
||||||
if (jobId) {
|
|
||||||
const afterData = JSON.stringify({
|
|
||||||
callsign: dbQSO.callsign,
|
|
||||||
qsoDate: dbQSO.qsoDate,
|
|
||||||
timeOn: dbQSO.timeOn,
|
|
||||||
band: dbQSO.band,
|
|
||||||
mode: dbQSO.mode,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(qsoChanges).values({
|
|
||||||
jobId,
|
|
||||||
qsoId: newQSO.id,
|
|
||||||
changeType: 'added',
|
|
||||||
beforeData: null,
|
|
||||||
afterData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addedCount++;
|
|
||||||
// Track added QSO (CALL and DATE)
|
|
||||||
addedQSOs.push({
|
addedQSOs.push({
|
||||||
id: newQSO.id,
|
|
||||||
callsign: dbQSO.callsign,
|
callsign: dbQSO.callsign,
|
||||||
date: dbQSO.qsoDate,
|
date: dbQSO.qsoDate,
|
||||||
band: dbQSO.band,
|
band: dbQSO.band,
|
||||||
mode: dbQSO.mode,
|
mode: dbQSO.mode,
|
||||||
});
|
});
|
||||||
}
|
addedCount++;
|
||||||
|
|
||||||
// Update job progress every 10 QSOs
|
|
||||||
if (jobId && (i + 1) % 10 === 0) {
|
|
||||||
await updateJobProgress(jobId, {
|
|
||||||
processed: i + 1,
|
|
||||||
message: `Processed ${i + 1}/${adifQSOs.length} QSOs from DCL...`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to process DCL QSO', {
|
logger.error('Failed to process DCL QSO in batch', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
qso: adifQSO,
|
qso: dbQSO,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
errors.push({ qso: adifQSO, error: error.message });
|
errors.push({ qso: dbQSO, error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch insert new QSOs
|
||||||
|
if (toInsert.length > 0) {
|
||||||
|
const inserted = await db.insert(qsos).values(toInsert).returning();
|
||||||
|
// Track inserted QSOs with their IDs for change tracking
|
||||||
|
if (jobId) {
|
||||||
|
for (let i = 0; i < inserted.length; i++) {
|
||||||
|
changeRecords.push({
|
||||||
|
jobId,
|
||||||
|
qsoId: inserted[i].id,
|
||||||
|
changeType: 'added',
|
||||||
|
beforeData: null,
|
||||||
|
afterData: JSON.stringify({
|
||||||
|
callsign: toInsert[i].callsign,
|
||||||
|
qsoDate: toInsert[i].qsoDate,
|
||||||
|
timeOn: toInsert[i].timeOn,
|
||||||
|
band: toInsert[i].band,
|
||||||
|
mode: toInsert[i].mode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Update addedQSOs with actual IDs
|
||||||
|
addedQSOs[addedCount - inserted.length + i].id = inserted[i].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch update existing QSOs
|
||||||
|
if (toUpdate.length > 0) {
|
||||||
|
for (const update of toUpdate) {
|
||||||
|
await db
|
||||||
|
.update(qsos)
|
||||||
|
.set(update.data)
|
||||||
|
.where(eq(qsos.id, update.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert change records
|
||||||
|
if (changeRecords.length > 0) {
|
||||||
|
await db.insert(qsoChanges).values(changeRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job progress after each batch
|
||||||
|
if (jobId) {
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
processed: endIdx,
|
||||||
|
message: `Processed ${endIdx}/${dbQSOs.length} QSOs from DCL...`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield to event loop after each batch to allow other requests
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
total: adifQSOs.length,
|
total: dbQSOs.length,
|
||||||
added: addedCount,
|
added: addedCount,
|
||||||
updated: updatedCount,
|
updated: updatedCount,
|
||||||
skipped: skippedCount,
|
skipped: skippedCount,
|
||||||
@@ -411,7 +460,8 @@ export async function syncQSOs(userId, dclApiKey, sinceDate = null, jobId = null
|
|||||||
|
|
||||||
// Invalidate award cache for this user since QSOs may have changed
|
// Invalidate award cache for this user since QSOs may have changed
|
||||||
const deletedCache = invalidateUserCache(userId);
|
const deletedCache = invalidateUserCache(userId);
|
||||||
logger.debug(`Invalidated ${deletedCache} cached award entries for user ${userId}`);
|
invalidateStatsCache(userId);
|
||||||
|
logger.debug(`Invalidated ${deletedCache} cached award entries and stats cache for user ${userId}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { qsos, qsoChanges } from '../db/schema/index.js';
|
|||||||
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
import { max, sql, eq, and, or, desc, like } from 'drizzle-orm';
|
||||||
import { updateJobProgress } from './job-queue.service.js';
|
import { updateJobProgress } from './job-queue.service.js';
|
||||||
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
import { parseADIF, normalizeBand, normalizeMode } from '../utils/adif-parser.js';
|
||||||
import { invalidateUserCache } from './cache.service.js';
|
import { invalidateUserCache, getCachedStats, setCachedStats, invalidateStatsCache } from './cache.service.js';
|
||||||
|
import { trackQueryPerformance, getPerformanceSummary, resetPerformanceMetrics } from './performance.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoTW (Logbook of the World) Service
|
* LoTW (Logbook of the World) Service
|
||||||
@@ -15,6 +16,35 @@ const MAX_RETRIES = 30;
|
|||||||
const RETRY_DELAY = 10000;
|
const RETRY_DELAY = 10000;
|
||||||
const REQUEST_TIMEOUT = 60000;
|
const REQUEST_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Sanitize search input to prevent injection and DoS
|
||||||
|
* Limits length and removes potentially harmful characters
|
||||||
|
*/
|
||||||
|
function sanitizeSearchInput(searchTerm) {
|
||||||
|
if (!searchTerm || typeof searchTerm !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
let sanitized = searchTerm.trim();
|
||||||
|
|
||||||
|
// Limit length (DoS prevention)
|
||||||
|
const MAX_SEARCH_LENGTH = 100;
|
||||||
|
if (sanitized.length > MAX_SEARCH_LENGTH) {
|
||||||
|
sanitized = sanitized.substring(0, MAX_SEARCH_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove potentially dangerous SQL pattern wildcards from user input
|
||||||
|
// We'll add our own wildcards for the LIKE query
|
||||||
|
// Note: Drizzle ORM escapes parameters, but this adds defense-in-depth
|
||||||
|
sanitized = sanitized.replace(/[%_\\]/g, '');
|
||||||
|
|
||||||
|
// Remove null bytes and other control characters
|
||||||
|
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, '');
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if LoTW response indicates the report is still being prepared
|
* Check if LoTW response indicates the report is still being prepared
|
||||||
*/
|
*/
|
||||||
@@ -181,7 +211,22 @@ function convertQSODatabaseFormat(adifQSO, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync QSOs from LoTW to database
|
* 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)
|
||||||
* @param {number} userId - User ID
|
* @param {number} userId - User ID
|
||||||
* @param {string} lotwUsername - LoTW username
|
* @param {string} lotwUsername - LoTW username
|
||||||
* @param {string} lotwPassword - LoTW password
|
* @param {string} lotwPassword - LoTW password
|
||||||
@@ -228,70 +273,83 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
const addedQSOs = [];
|
const addedQSOs = [];
|
||||||
const updatedQSOs = [];
|
const updatedQSOs = [];
|
||||||
|
|
||||||
for (let i = 0; i < adifQSOs.length; i++) {
|
// Convert all QSOs to database format
|
||||||
const qsoData = adifQSOs[i];
|
const dbQSOs = adifQSOs.map(qsoData => convertQSODatabaseFormat(qsoData, userId));
|
||||||
|
|
||||||
try {
|
// Batch size for processing
|
||||||
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
|
const BATCH_SIZE = 100;
|
||||||
|
const totalBatches = Math.ceil(dbQSOs.length / BATCH_SIZE);
|
||||||
|
|
||||||
const existing = await db
|
for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||||
|
const startIdx = batchNum * BATCH_SIZE;
|
||||||
|
const endIdx = Math.min(startIdx + BATCH_SIZE, dbQSOs.length);
|
||||||
|
const batch = dbQSOs.slice(startIdx, endIdx);
|
||||||
|
|
||||||
|
// Build condition for batch duplicate check
|
||||||
|
// Get unique callsigns, dates, bands, modes from batch
|
||||||
|
const batchCallsigns = [...new Set(batch.map(q => q.callsign))];
|
||||||
|
const batchDates = [...new Set(batch.map(q => q.qsoDate))];
|
||||||
|
|
||||||
|
// Fetch all existing QSOs that could match this batch in one query
|
||||||
|
const existingQSOs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(qsos)
|
.from(qsos)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(qsos.userId, userId),
|
eq(qsos.userId, userId),
|
||||||
eq(qsos.callsign, dbQSO.callsign),
|
// Match callsigns OR dates from this batch
|
||||||
eq(qsos.qsoDate, dbQSO.qsoDate),
|
sql`(${qsos.callsign} IN ${batchCallsigns} OR ${qsos.qsoDate} IN ${batchDates})`
|
||||||
eq(qsos.timeOn, dbQSO.timeOn),
|
|
||||||
eq(qsos.band, dbQSO.band),
|
|
||||||
eq(qsos.mode, dbQSO.mode)
|
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
// Build lookup map for existing QSOs
|
||||||
const existingQSO = existing[0];
|
const existingMap = new Map();
|
||||||
|
for (const existing of existingQSOs) {
|
||||||
|
const key = getQSOKey(existing);
|
||||||
|
existingMap.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process batch
|
||||||
|
const toInsert = [];
|
||||||
|
const toUpdate = [];
|
||||||
|
const changeRecords = [];
|
||||||
|
|
||||||
|
for (const dbQSO of batch) {
|
||||||
|
try {
|
||||||
|
const key = getQSOKey(dbQSO);
|
||||||
|
const existingQSO = existingMap.get(key);
|
||||||
|
|
||||||
|
if (existingQSO) {
|
||||||
// Check if LoTW confirmation data has changed
|
// Check if LoTW confirmation data has changed
|
||||||
const confirmationChanged =
|
const confirmationChanged =
|
||||||
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus ||
|
existingQSO.lotwQslRstatus !== dbQSO.lotwQslRstatus ||
|
||||||
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate;
|
existingQSO.lotwQslRdate !== dbQSO.lotwQslRdate;
|
||||||
|
|
||||||
if (confirmationChanged) {
|
if (confirmationChanged) {
|
||||||
// Record before state for rollback
|
toUpdate.push({
|
||||||
const beforeData = JSON.stringify({
|
id: existingQSO.id,
|
||||||
lotwQslRstatus: existingQSO.lotwQslRstatus,
|
|
||||||
lotwQslRdate: existingQSO.lotwQslRdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(qsos)
|
|
||||||
.set({
|
|
||||||
lotwQslRdate: dbQSO.lotwQslRdate,
|
lotwQslRdate: dbQSO.lotwQslRdate,
|
||||||
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
||||||
lotwSyncedAt: dbQSO.lotwSyncedAt,
|
lotwSyncedAt: dbQSO.lotwSyncedAt,
|
||||||
})
|
|
||||||
.where(eq(qsos.id, existingQSO.id));
|
|
||||||
|
|
||||||
// Record after state for rollback
|
|
||||||
const afterData = JSON.stringify({
|
|
||||||
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
|
||||||
lotwQslRdate: dbQSO.lotwQslRdate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track change in qso_changes table if jobId provided
|
// Track change for rollback
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
await db.insert(qsoChanges).values({
|
changeRecords.push({
|
||||||
jobId,
|
jobId,
|
||||||
qsoId: existingQSO.id,
|
qsoId: existingQSO.id,
|
||||||
changeType: 'updated',
|
changeType: 'updated',
|
||||||
beforeData,
|
beforeData: JSON.stringify({
|
||||||
afterData,
|
lotwQslRstatus: existingQSO.lotwQslRstatus,
|
||||||
|
lotwQslRdate: existingQSO.lotwQslRdate,
|
||||||
|
}),
|
||||||
|
afterData: JSON.stringify({
|
||||||
|
lotwQslRstatus: dbQSO.lotwQslRstatus,
|
||||||
|
lotwQslRdate: dbQSO.lotwQslRdate,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedCount++;
|
|
||||||
// Track updated QSO (CALL and DATE)
|
|
||||||
updatedQSOs.push({
|
updatedQSOs.push({
|
||||||
id: existingQSO.id,
|
id: existingQSO.id,
|
||||||
callsign: dbQSO.callsign,
|
callsign: dbQSO.callsign,
|
||||||
@@ -299,66 +357,93 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
band: dbQSO.band,
|
band: dbQSO.band,
|
||||||
mode: dbQSO.mode,
|
mode: dbQSO.mode,
|
||||||
});
|
});
|
||||||
|
updatedCount++;
|
||||||
} else {
|
} else {
|
||||||
// Skip - same data
|
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert new QSO
|
// New QSO to insert
|
||||||
const [newQSO] = await db.insert(qsos).values(dbQSO).returning();
|
toInsert.push(dbQSO);
|
||||||
|
|
||||||
// Track change in qso_changes table if jobId provided
|
|
||||||
if (jobId) {
|
|
||||||
const afterData = JSON.stringify({
|
|
||||||
callsign: dbQSO.callsign,
|
|
||||||
qsoDate: dbQSO.qsoDate,
|
|
||||||
timeOn: dbQSO.timeOn,
|
|
||||||
band: dbQSO.band,
|
|
||||||
mode: dbQSO.mode,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(qsoChanges).values({
|
|
||||||
jobId,
|
|
||||||
qsoId: newQSO.id,
|
|
||||||
changeType: 'added',
|
|
||||||
beforeData: null,
|
|
||||||
afterData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addedCount++;
|
|
||||||
// Track added QSO (CALL and DATE)
|
|
||||||
addedQSOs.push({
|
addedQSOs.push({
|
||||||
id: newQSO.id,
|
|
||||||
callsign: dbQSO.callsign,
|
callsign: dbQSO.callsign,
|
||||||
date: dbQSO.qsoDate,
|
date: dbQSO.qsoDate,
|
||||||
band: dbQSO.band,
|
band: dbQSO.band,
|
||||||
mode: dbQSO.mode,
|
mode: dbQSO.mode,
|
||||||
});
|
});
|
||||||
}
|
addedCount++;
|
||||||
|
|
||||||
// Update job progress every 10 QSOs
|
|
||||||
if (jobId && (i + 1) % 10 === 0) {
|
|
||||||
await updateJobProgress(jobId, {
|
|
||||||
processed: i + 1,
|
|
||||||
message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error processing QSO', { error: error.message, jobId, qso: qsoData });
|
logger.error('Error processing QSO in batch', { error: error.message, jobId, qso: dbQSO });
|
||||||
errors.push({ qso: qsoData, error: error.message });
|
errors.push({ qso: dbQSO, error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
|
// Batch insert new QSOs
|
||||||
|
if (toInsert.length > 0) {
|
||||||
|
const inserted = await db.insert(qsos).values(toInsert).returning();
|
||||||
|
// Track inserted QSOs with their IDs for change tracking
|
||||||
|
if (jobId) {
|
||||||
|
for (let i = 0; i < inserted.length; i++) {
|
||||||
|
changeRecords.push({
|
||||||
|
jobId,
|
||||||
|
qsoId: inserted[i].id,
|
||||||
|
changeType: 'added',
|
||||||
|
beforeData: null,
|
||||||
|
afterData: JSON.stringify({
|
||||||
|
callsign: toInsert[i].callsign,
|
||||||
|
qsoDate: toInsert[i].qsoDate,
|
||||||
|
timeOn: toInsert[i].timeOn,
|
||||||
|
band: toInsert[i].band,
|
||||||
|
mode: toInsert[i].mode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Update addedQSOs with actual IDs
|
||||||
|
addedQSOs[addedCount - inserted.length + i].id = inserted[i].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate award cache for this user since QSOs may have changed
|
// Batch update existing QSOs
|
||||||
|
if (toUpdate.length > 0) {
|
||||||
|
for (const update of toUpdate) {
|
||||||
|
await db
|
||||||
|
.update(qsos)
|
||||||
|
.set({
|
||||||
|
lotwQslRdate: update.lotwQslRdate,
|
||||||
|
lotwQslRstatus: update.lotwQslRstatus,
|
||||||
|
lotwSyncedAt: update.lotwSyncedAt,
|
||||||
|
})
|
||||||
|
.where(eq(qsos.id, update.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert change records
|
||||||
|
if (changeRecords.length > 0) {
|
||||||
|
await db.insert(qsoChanges).values(changeRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job progress after each batch
|
||||||
|
if (jobId) {
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
processed: endIdx,
|
||||||
|
message: `Processed ${endIdx}/${dbQSOs.length} QSOs...`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield to event loop after each batch to allow other requests
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('LoTW sync completed', { total: dbQSOs.length, added: addedCount, updated: updatedCount, skipped: skippedCount, jobId });
|
||||||
|
|
||||||
|
// Invalidate award and stats cache for this user since QSOs may have changed
|
||||||
const deletedCache = invalidateUserCache(userId);
|
const deletedCache = invalidateUserCache(userId);
|
||||||
logger.debug(`Invalidated ${deletedCache} cached award entries for user ${userId}`);
|
invalidateStatsCache(userId);
|
||||||
|
logger.debug(`Invalidated ${deletedCache} cached award entries and stats cache for user ${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
total: adifQSOs.length,
|
total: dbQSOs.length,
|
||||||
added: addedCount,
|
added: addedCount,
|
||||||
updated: updatedCount,
|
updated: updatedCount,
|
||||||
skipped: skippedCount,
|
skipped: skippedCount,
|
||||||
@@ -419,13 +504,17 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
|
|||||||
|
|
||||||
// Search filter: callsign, entity, or grid
|
// Search filter: callsign, entity, or grid
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const searchTerm = `%${filters.search}%`;
|
// SECURITY: Sanitize search input to prevent injection
|
||||||
|
const sanitized = sanitizeSearchInput(filters.search);
|
||||||
|
if (sanitized) {
|
||||||
|
const searchTerm = `%${sanitized}%`;
|
||||||
conditions.push(or(
|
conditions.push(or(
|
||||||
like(qsos.callsign, searchTerm),
|
like(qsos.callsign, searchTerm),
|
||||||
like(qsos.entity, searchTerm),
|
like(qsos.entity, searchTerm),
|
||||||
like(qsos.grid, searchTerm)
|
like(qsos.grid, searchTerm)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
|
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
@@ -461,26 +550,40 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
|
|||||||
* Get QSO statistics for a user
|
* Get QSO statistics for a user
|
||||||
*/
|
*/
|
||||||
export async function getQSOStats(userId) {
|
export async function getQSOStats(userId) {
|
||||||
const allQSOs = await db.select().from(qsos).where(eq(qsos.userId, userId));
|
// Check cache first
|
||||||
const confirmed = allQSOs.filter((q) => q.lotwQslRstatus === 'Y' || q.dclQslRstatus === 'Y');
|
const cached = getCachedStats(userId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueEntities = new Set();
|
// Calculate stats from database with performance tracking
|
||||||
const uniqueBands = new Set();
|
const stats = await trackQueryPerformance('getQSOStats', async () => {
|
||||||
const uniqueModes = new Set();
|
const [basicStats, uniqueStats] = await Promise.all([
|
||||||
|
db.select({
|
||||||
|
total: sql`CAST(COUNT(*) AS INTEGER)`,
|
||||||
|
confirmed: sql`CAST(SUM(CASE WHEN lotw_qsl_rstatus = 'Y' OR dcl_qsl_rstatus = 'Y' THEN 1 ELSE 0 END) AS INTEGER)`
|
||||||
|
}).from(qsos).where(eq(qsos.userId, userId)),
|
||||||
|
|
||||||
allQSOs.forEach((q) => {
|
db.select({
|
||||||
if (q.entity) uniqueEntities.add(q.entity);
|
uniqueEntities: sql`CAST(COUNT(DISTINCT entity) AS INTEGER)`,
|
||||||
if (q.band) uniqueBands.add(q.band);
|
uniqueBands: sql`CAST(COUNT(DISTINCT band) AS INTEGER)`,
|
||||||
if (q.mode) uniqueModes.add(q.mode);
|
uniqueModes: sql`CAST(COUNT(DISTINCT mode) AS INTEGER)`
|
||||||
});
|
}).from(qsos).where(eq(qsos.userId, userId))
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: allQSOs.length,
|
total: basicStats[0].total,
|
||||||
confirmed: confirmed.length,
|
confirmed: basicStats[0].confirmed || 0,
|
||||||
uniqueEntities: uniqueEntities.size,
|
uniqueEntities: uniqueStats[0].uniqueEntities || 0,
|
||||||
uniqueBands: uniqueBands.size,
|
uniqueBands: uniqueStats[0].uniqueBands || 0,
|
||||||
uniqueModes: uniqueModes.size,
|
uniqueModes: uniqueStats[0].uniqueModes || 0,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
setCachedStats(userId, stats);
|
||||||
|
|
||||||
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
274
src/backend/services/performance.service.js
Normal file
274
src/backend/services/performance.service.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Performance Monitoring Service
|
||||||
|
*
|
||||||
|
* Tracks query performance metrics to identify slow queries and detect regressions.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Track individual query performance
|
||||||
|
* - Calculate averages and percentiles
|
||||||
|
* - Detect slow queries automatically
|
||||||
|
* - Provide performance statistics for monitoring
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const result = await trackQueryPerformance('getQSOStats', async () => {
|
||||||
|
* return await someExpensiveOperation();
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Performance metrics storage
|
||||||
|
const queryMetrics = new Map();
|
||||||
|
|
||||||
|
// Thresholds for slow queries
|
||||||
|
const SLOW_QUERY_THRESHOLD = 100; // 100ms = slow
|
||||||
|
const CRITICAL_QUERY_THRESHOLD = 500; // 500ms = critical
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track query performance and log results
|
||||||
|
* @param {string} queryName - Name of the query/operation
|
||||||
|
* @param {Function} fn - Async function to execute and track
|
||||||
|
* @returns {Promise<any>} Result of the function
|
||||||
|
*/
|
||||||
|
export async function trackQueryPerformance(queryName, fn) {
|
||||||
|
const start = performance.now();
|
||||||
|
let result;
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await fn();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
throw err; // Re-throw error
|
||||||
|
} finally {
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
recordQueryMetric(queryName, duration, error);
|
||||||
|
|
||||||
|
// Log slow queries
|
||||||
|
if (duration > CRITICAL_QUERY_THRESHOLD) {
|
||||||
|
console.error(`🚨 CRITICAL SLOW QUERY: ${queryName} took ${duration.toFixed(2)}ms`);
|
||||||
|
} else if (duration > SLOW_QUERY_THRESHOLD) {
|
||||||
|
console.warn(`⚠️ SLOW QUERY: ${queryName} took ${duration.toFixed(2)}ms`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Query Performance: ${queryName} - ${duration.toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a query metric for later analysis
|
||||||
|
* @param {string} queryName - Name of the query
|
||||||
|
* @param {number} duration - Query duration in milliseconds
|
||||||
|
* @param {Error|null} error - Error if query failed
|
||||||
|
*/
|
||||||
|
function recordQueryMetric(queryName, duration, error = null) {
|
||||||
|
if (!queryMetrics.has(queryName)) {
|
||||||
|
queryMetrics.set(queryName, {
|
||||||
|
count: 0,
|
||||||
|
totalTime: 0,
|
||||||
|
minTime: Infinity,
|
||||||
|
maxTime: 0,
|
||||||
|
errors: 0,
|
||||||
|
durations: [] // Keep recent durations for percentile calculation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = queryMetrics.get(queryName);
|
||||||
|
metrics.count++;
|
||||||
|
metrics.totalTime += duration;
|
||||||
|
metrics.minTime = Math.min(metrics.minTime, duration);
|
||||||
|
metrics.maxTime = Math.max(metrics.maxTime, duration);
|
||||||
|
if (error) metrics.errors++;
|
||||||
|
|
||||||
|
// Keep last 100 durations for percentile calculation
|
||||||
|
metrics.durations.push(duration);
|
||||||
|
if (metrics.durations.length > 100) {
|
||||||
|
metrics.durations.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get performance statistics for a specific query or all queries
|
||||||
|
* @param {string|null} queryName - Query name or null for all queries
|
||||||
|
* @returns {object} Performance statistics
|
||||||
|
*/
|
||||||
|
export function getPerformanceStats(queryName = null) {
|
||||||
|
if (queryName) {
|
||||||
|
const metrics = queryMetrics.get(queryName);
|
||||||
|
if (!metrics) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return calculateQueryStats(queryName, metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats for all queries
|
||||||
|
const stats = {};
|
||||||
|
for (const [name, metrics] of queryMetrics.entries()) {
|
||||||
|
stats[name] = calculateQueryStats(name, metrics);
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate statistics for a query
|
||||||
|
* @param {string} queryName - Name of the query
|
||||||
|
* @param {object} metrics - Raw metrics
|
||||||
|
* @returns {object} Calculated statistics
|
||||||
|
*/
|
||||||
|
function calculateQueryStats(queryName, metrics) {
|
||||||
|
const avgTime = metrics.totalTime / metrics.count;
|
||||||
|
|
||||||
|
// Calculate percentiles (P50, P95, P99)
|
||||||
|
const sorted = [...metrics.durations].sort((a, b) => a - b);
|
||||||
|
const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0;
|
||||||
|
const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;
|
||||||
|
const p99 = sorted[Math.floor(sorted.length * 0.99)] || 0;
|
||||||
|
|
||||||
|
// Determine performance rating
|
||||||
|
let rating = 'EXCELLENT';
|
||||||
|
if (avgTime > CRITICAL_QUERY_THRESHOLD) {
|
||||||
|
rating = 'CRITICAL';
|
||||||
|
} else if (avgTime > SLOW_QUERY_THRESHOLD) {
|
||||||
|
rating = 'SLOW';
|
||||||
|
} else if (avgTime > 50) {
|
||||||
|
rating = 'GOOD';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: queryName,
|
||||||
|
count: metrics.count,
|
||||||
|
avgTime: avgTime.toFixed(2) + 'ms',
|
||||||
|
minTime: metrics.minTime.toFixed(2) + 'ms',
|
||||||
|
maxTime: metrics.maxTime.toFixed(2) + 'ms',
|
||||||
|
p50: p50.toFixed(2) + 'ms',
|
||||||
|
p95: p95.toFixed(2) + 'ms',
|
||||||
|
p99: p99.toFixed(2) + 'ms',
|
||||||
|
errors: metrics.errors,
|
||||||
|
errorRate: ((metrics.errors / metrics.count) * 100).toFixed(2) + '%',
|
||||||
|
rating
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get overall performance summary
|
||||||
|
* @returns {object} Summary of all query performance
|
||||||
|
*/
|
||||||
|
export function getPerformanceSummary() {
|
||||||
|
if (queryMetrics.size === 0) {
|
||||||
|
return {
|
||||||
|
totalQueries: 0,
|
||||||
|
totalTime: 0,
|
||||||
|
avgTime: '0ms',
|
||||||
|
slowQueries: 0,
|
||||||
|
criticalQueries: 0,
|
||||||
|
topSlowest: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalQueries = 0;
|
||||||
|
let totalTime = 0;
|
||||||
|
let slowQueries = 0;
|
||||||
|
let criticalQueries = 0;
|
||||||
|
const allStats = [];
|
||||||
|
|
||||||
|
for (const [name, metrics] of queryMetrics.entries()) {
|
||||||
|
const stats = calculateQueryStats(name, metrics);
|
||||||
|
totalQueries += metrics.count;
|
||||||
|
totalTime += metrics.totalTime;
|
||||||
|
|
||||||
|
const avgTime = metrics.totalTime / metrics.count;
|
||||||
|
if (avgTime > CRITICAL_QUERY_THRESHOLD) {
|
||||||
|
criticalQueries++;
|
||||||
|
} else if (avgTime > SLOW_QUERY_THRESHOLD) {
|
||||||
|
slowQueries++;
|
||||||
|
}
|
||||||
|
|
||||||
|
allStats.push(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by average time (slowest first)
|
||||||
|
const topSlowest = allStats
|
||||||
|
.sort((a, b) => parseFloat(b.avgTime) - parseFloat(a.avgTime))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalQueries,
|
||||||
|
totalTime: totalTime.toFixed(2) + 'ms',
|
||||||
|
avgTime: (totalTime / totalQueries).toFixed(2) + 'ms',
|
||||||
|
slowQueries,
|
||||||
|
criticalQueries,
|
||||||
|
topSlowest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset performance metrics (for testing)
|
||||||
|
*/
|
||||||
|
export function resetPerformanceMetrics() {
|
||||||
|
queryMetrics.clear();
|
||||||
|
console.log('Performance metrics cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get slow queries (above threshold)
|
||||||
|
* @param {number} threshold - Duration threshold in ms (default: 100ms)
|
||||||
|
* @returns {Array} Array of slow query statistics
|
||||||
|
*/
|
||||||
|
export function getSlowQueries(threshold = SLOW_QUERY_THRESHOLD) {
|
||||||
|
const slowQueries = [];
|
||||||
|
|
||||||
|
for (const [name, metrics] of queryMetrics.entries()) {
|
||||||
|
const avgTime = metrics.totalTime / metrics.count;
|
||||||
|
if (avgTime > threshold) {
|
||||||
|
slowQueries.push(calculateQueryStats(name, metrics));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by average time (slowest first)
|
||||||
|
return slowQueries.sort((a, b) => parseFloat(b.avgTime) - parseFloat(a.avgTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance monitoring utility for database queries
|
||||||
|
* @param {string} queryName - Name of the query
|
||||||
|
* @param {Function} queryFn - Query function to track
|
||||||
|
* @returns {Promise<any>} Query result
|
||||||
|
*/
|
||||||
|
export async function trackQuery(queryName, queryFn) {
|
||||||
|
return trackQueryPerformance(queryName, queryFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if performance is degrading (compares recent vs overall average)
|
||||||
|
* @param {string} queryName - Query name to check
|
||||||
|
* @param {number} windowSize - Number of recent queries to compare (default: 10)
|
||||||
|
* @returns {object} Degradation status
|
||||||
|
*/
|
||||||
|
export function checkPerformanceDegradation(queryName, windowSize = 10) {
|
||||||
|
const metrics = queryMetrics.get(queryName);
|
||||||
|
if (!metrics || metrics.durations.length < windowSize * 2) {
|
||||||
|
return {
|
||||||
|
degraded: false,
|
||||||
|
message: 'Insufficient data'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent queries (last N)
|
||||||
|
const recentDurations = metrics.durations.slice(-windowSize);
|
||||||
|
const avgRecent = recentDurations.reduce((a, b) => a + b, 0) / recentDurations.length;
|
||||||
|
|
||||||
|
// Overall average
|
||||||
|
const avgOverall = metrics.totalTime / metrics.count;
|
||||||
|
|
||||||
|
// Check if recent is 2x worse than overall
|
||||||
|
const degraded = avgRecent > avgOverall * 2;
|
||||||
|
const change = ((avgRecent - avgOverall) / avgOverall * 100).toFixed(2) + '%';
|
||||||
|
|
||||||
|
return {
|
||||||
|
degraded,
|
||||||
|
avgRecent: avgRecent.toFixed(2) + 'ms',
|
||||||
|
avgOverall: avgOverall.toFixed(2) + 'ms',
|
||||||
|
change,
|
||||||
|
message: degraded ? `Performance degraded by ${change}` : 'Performance stable'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -86,3 +86,35 @@ export const jobsAPI = {
|
|||||||
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
||||||
cancel: (jobId) => apiRequest(`/jobs/${jobId}`, { method: 'DELETE' }),
|
cancel: (jobId) => apiRequest(`/jobs/${jobId}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Admin API
|
||||||
|
export const adminAPI = {
|
||||||
|
getStats: () => apiRequest('/admin/stats'),
|
||||||
|
|
||||||
|
getUsers: () => apiRequest('/admin/users'),
|
||||||
|
|
||||||
|
getUserDetails: (userId) => apiRequest(`/admin/users/${userId}`),
|
||||||
|
|
||||||
|
updateUserRole: (userId, isAdmin) => apiRequest(`/admin/users/${userId}/role`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ isAdmin }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteUser: (userId) => apiRequest(`/admin/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
impersonate: (userId) => apiRequest(`/admin/impersonate/${userId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
stopImpersonation: () => apiRequest('/admin/impersonate/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
getImpersonationStatus: () => apiRequest('/admin/impersonation/status'),
|
||||||
|
|
||||||
|
getActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions?limit=${limit}&offset=${offset}`),
|
||||||
|
|
||||||
|
getMyActions: (limit = 50, offset = 0) => apiRequest(`/admin/actions/my?limit=${limit}&offset=${offset}`),
|
||||||
|
};
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
<a href="/awards" class="nav-link">Awards</a>
|
<a href="/awards" class="nav-link">Awards</a>
|
||||||
<a href="/qsos" class="nav-link">QSOs</a>
|
<a href="/qsos" class="nav-link">QSOs</a>
|
||||||
<a href="/settings" class="nav-link">Settings</a>
|
<a href="/settings" class="nav-link">Settings</a>
|
||||||
|
{#if $auth.user?.isAdmin}
|
||||||
|
<a href="/admin" class="nav-link admin-link">Admin</a>
|
||||||
|
{/if}
|
||||||
<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>
|
||||||
@@ -119,6 +122,16 @@
|
|||||||
background-color: rgba(255, 107, 107, 0.1);
|
background-color: rgba(255, 107, 107, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-link {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link:hover {
|
||||||
|
background-color: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
|
|||||||
1016
src/frontend/src/routes/admin/+page.svelte
Normal file
1016
src/frontend/src/routes/admin/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,14 +25,12 @@
|
|||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
const response = await authAPI.getProfile();
|
const response = await authAPI.getProfile();
|
||||||
console.log('Loaded profile:', response.user);
|
|
||||||
if (response.user) {
|
if (response.user) {
|
||||||
lotwUsername = response.user.lotwUsername || '';
|
lotwUsername = response.user.lotwUsername || '';
|
||||||
lotwPassword = ''; // Never pre-fill password for security
|
lotwPassword = ''; // Never pre-fill password for security
|
||||||
hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
|
hasLoTWCredentials = !!(response.user.lotwUsername && response.user.lotwPassword);
|
||||||
dclApiKey = response.user.dclApiKey || '';
|
dclApiKey = response.user.dclApiKey || '';
|
||||||
hasDCLCredentials = !!response.user.dclApiKey;
|
hasDCLCredentials = !!response.user.dclApiKey;
|
||||||
console.log('Has LoTW credentials:', hasLoTWCredentials, 'Has DCL credentials:', hasDCLCredentials);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load profile:', err);
|
console.error('Failed to load profile:', err);
|
||||||
@@ -50,8 +48,6 @@
|
|||||||
error = null;
|
error = null;
|
||||||
successLoTW = false;
|
successLoTW = false;
|
||||||
|
|
||||||
console.log('Saving LoTW credentials:', { lotwUsername, hasPassword: !!lotwPassword });
|
|
||||||
|
|
||||||
await authAPI.updateLoTWCredentials({
|
await authAPI.updateLoTWCredentials({
|
||||||
lotwUsername,
|
lotwUsername,
|
||||||
lotwPassword
|
lotwPassword
|
||||||
@@ -78,8 +74,6 @@
|
|||||||
error = null;
|
error = null;
|
||||||
successDCL = false;
|
successDCL = false;
|
||||||
|
|
||||||
console.log('Saving DCL credentials:', { hasApiKey: !!dclApiKey });
|
|
||||||
|
|
||||||
await authAPI.updateDCLCredentials({
|
await authAPI.updateDCLCredentials({
|
||||||
dclApiKey
|
dclApiKey
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user