Add structured logging, navigation bar, and code cleanup
## Backend - Add Pino logging framework with timestamps and structured output - Replace all console.error statements (49+) with proper logging levels - Fix Drizzle ORM bug: replace invalid .get() calls with .limit(1) - Remove unused auth routes file (already in index.js) - Make internal functions private (remove unnecessary exports) - Simplify code by removing excessive debug logging ## Frontend - Add navigation bar to layout with: - User's callsign display - Navigation links (Dashboard, QSOs, Settings) - Logout button with red color distinction - Navigation only shows when user is logged in - Dark themed design matching footer ## Documentation - Update README.md with new project structure - Update docs/DOCUMENTATION.md with logging and nav bar info - Add logger.js to configuration section Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
12
README.md
12
README.md
@@ -24,6 +24,7 @@ A web application for amateur radio operators to track QSOs (contacts) and award
|
|||||||
- **Framework**: Elysia.js
|
- **Framework**: Elysia.js
|
||||||
- **Database**: SQLite with Drizzle ORM
|
- **Database**: SQLite with Drizzle ORM
|
||||||
- **Authentication**: JWT tokens
|
- **Authentication**: JWT tokens
|
||||||
|
- **Logging**: Pino with structured logging and timestamps
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- **Framework**: SvelteKit
|
- **Framework**: SvelteKit
|
||||||
@@ -38,7 +39,8 @@ award/
|
|||||||
│ ├── backend/
|
│ ├── backend/
|
||||||
│ │ ├── config/
|
│ │ ├── config/
|
||||||
│ │ │ ├── database.js # Database connection
|
│ │ │ ├── database.js # Database connection
|
||||||
│ │ │ └── jwt.js # JWT configuration
|
│ │ │ ├── jwt.js # JWT configuration
|
||||||
|
│ │ │ └── logger.js # Pino logging configuration
|
||||||
│ │ ├── db/
|
│ │ ├── db/
|
||||||
│ │ │ └── schema/
|
│ │ │ └── schema/
|
||||||
│ │ │ └── index.js # Database schema (users, qsos, sync_jobs)
|
│ │ │ └── index.js # Database schema (users, qsos, sync_jobs)
|
||||||
@@ -53,9 +55,11 @@ award/
|
|||||||
│ │ │ ├── api.js # API client
|
│ │ │ ├── api.js # API client
|
||||||
│ │ │ └── stores.js # Svelte stores (auth)
|
│ │ │ └── stores.js # Svelte stores (auth)
|
||||||
│ │ └── routes/
|
│ │ └── routes/
|
||||||
│ │ ├── +page.svelte # Main menu
|
│ │ ├── +layout.svelte # Navigation bar & layout
|
||||||
│ │ ├── login/+page.svelte # Login page
|
│ │ ├── +page.svelte # Dashboard
|
||||||
│ │ ├── register/+page.svelte # Registration page
|
│ │ ├── auth/
|
||||||
|
│ │ │ ├── login/+page.svelte # Login page
|
||||||
|
│ │ │ └── register/+page.svelte # Registration page
|
||||||
│ │ ├── qsos/+page.svelte # QSO log with pagination
|
│ │ ├── qsos/+page.svelte # QSO log with pagination
|
||||||
│ │ └── settings/+page.svelte # Settings & LoTW credentials
|
│ │ └── settings/+page.svelte # Settings & LoTW credentials
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
|
|||||||
56
bun.lock
56
bun.lock
@@ -11,6 +11,8 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"elysia": "^1.4.22",
|
"elysia": "^1.4.22",
|
||||||
|
"pino": "^10.2.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
@@ -117,6 +119,8 @@
|
|||||||
|
|
||||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||||
|
|
||||||
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.47", "", {}, "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw=="],
|
"@sinclair/typebox": ["@sinclair/typebox@0.34.47", "", {}, "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw=="],
|
||||||
|
|
||||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
||||||
@@ -129,18 +133,24 @@
|
|||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
|
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||||
|
|
||||||
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||||
|
|
||||||
|
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
|
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
|
||||||
|
|
||||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
|
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||||
@@ -151,14 +161,20 @@
|
|||||||
|
|
||||||
"elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="],
|
"elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
"exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="],
|
"exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="],
|
||||||
|
|
||||||
|
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
|
||||||
|
|
||||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
|
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||||
|
|
||||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
||||||
@@ -167,16 +183,22 @@
|
|||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||||
|
|
||||||
|
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
|
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||||
|
|
||||||
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
||||||
|
|
||||||
"libsql": ["libsql@0.5.22", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.22", "@libsql/darwin-x64": "0.5.22", "@libsql/linux-arm-gnueabihf": "0.5.22", "@libsql/linux-arm-musleabihf": "0.5.22", "@libsql/linux-arm64-gnu": "0.5.22", "@libsql/linux-arm64-musl": "0.5.22", "@libsql/linux-x64-gnu": "0.5.22", "@libsql/linux-x64-musl": "0.5.22", "@libsql/win32-x64-msvc": "0.5.22" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA=="],
|
"libsql": ["libsql@0.5.22", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.22", "@libsql/darwin-x64": "0.5.22", "@libsql/linux-arm-gnueabihf": "0.5.22", "@libsql/linux-arm-musleabihf": "0.5.22", "@libsql/linux-arm64-gnu": "0.5.22", "@libsql/linux-arm64-musl": "0.5.22", "@libsql/linux-x64-gnu": "0.5.22", "@libsql/linux-x64-musl": "0.5.22", "@libsql/win32-x64-msvc": "0.5.22" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA=="],
|
||||||
|
|
||||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||||
|
|
||||||
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
||||||
@@ -187,18 +209,50 @@
|
|||||||
|
|
||||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||||
|
|
||||||
|
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||||
|
|
||||||
|
"pino": ["pino@10.2.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-NFnZqUliT+OHkRXVSf8vdOr13N1wv31hRryVjqbreVh/SDCNaI6mnRDDq89HVRCbem1SAl7yj04OANeqP0nT6A=="],
|
||||||
|
|
||||||
|
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||||
|
|
||||||
|
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
|
||||||
|
|
||||||
|
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||||
|
|
||||||
|
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||||
|
|
||||||
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
|
||||||
|
|
||||||
|
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||||
|
|
||||||
|
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||||
|
|
||||||
|
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
|
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||||
|
|
||||||
|
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||||
|
|
||||||
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
||||||
|
|
||||||
|
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||||
|
|
||||||
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
@@ -215,6 +269,8 @@
|
|||||||
|
|
||||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ The Ham Radio Award Portal is a full-stack web application designed to help amat
|
|||||||
- **ORM**: Drizzle ORM - Type-safe database queries
|
- **ORM**: Drizzle ORM - Type-safe database queries
|
||||||
- **Authentication**: JWT tokens via `@elysiajs/jwt`
|
- **Authentication**: JWT tokens via `@elysiajs/jwt`
|
||||||
- **Password Hashing**: bcrypt
|
- **Password Hashing**: bcrypt
|
||||||
|
- **Logging**: Pino - Structured logging with timestamps and log levels
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
- **Framework**: SvelteKit - Modern reactive framework
|
- **Framework**: SvelteKit - Modern reactive framework
|
||||||
@@ -118,7 +119,8 @@ Defines the database structure using Drizzle ORM schema builder.
|
|||||||
#### 4. Configuration (`src/backend/config/`)
|
#### 4. Configuration (`src/backend/config/`)
|
||||||
|
|
||||||
- **database.js**: Database connection and client initialization
|
- **database.js**: Database connection and client initialization
|
||||||
- **constants.js**: Application constants (JWT expiration, etc.)
|
- **jwt.js**: JWT secret configuration
|
||||||
|
- **logger.js**: Pino logger configuration with structured logging and timestamps
|
||||||
|
|
||||||
### Frontend Components
|
### Frontend Components
|
||||||
|
|
||||||
@@ -130,7 +132,14 @@ Defines the database structure using Drizzle ORM schema builder.
|
|||||||
- **`/qsos`**: QSO logbook with filtering and LoTW sync
|
- **`/qsos`**: QSO logbook with filtering and LoTW sync
|
||||||
- **`/settings`**: LoTW credentials management
|
- **`/settings`**: LoTW credentials management
|
||||||
|
|
||||||
#### 2. Libraries
|
#### 2. Layout (`+layout.svelte`)
|
||||||
|
|
||||||
|
Global layout component providing:
|
||||||
|
- **Navigation bar**: Shows user's callsign, navigation links (Dashboard, QSOs, Settings), and logout button
|
||||||
|
- Only visible when user is logged in
|
||||||
|
- Responsive design with dark theme matching footer
|
||||||
|
|
||||||
|
#### 3. Libraries
|
||||||
|
|
||||||
**API Client** (`src/frontend/src/lib/api.js`)
|
**API Client** (`src/frontend/src/lib/api.js`)
|
||||||
- Centralized API communication
|
- Centralized API communication
|
||||||
@@ -172,13 +181,15 @@ award/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── backend/ # Backend server code
|
│ ├── backend/ # Backend server code
|
||||||
│ │ ├── config/
|
│ │ ├── config/
|
||||||
│ │ │ ├── constants.js
|
│ │ │ ├── database.js # Database connection
|
||||||
│ │ │ └── database.js
|
│ │ │ ├── jwt.js # JWT configuration
|
||||||
|
│ │ │ └── logger.js # Pino logging configuration
|
||||||
│ │ ├── db/
|
│ │ ├── db/
|
||||||
│ │ │ └── schema/
|
│ │ │ └── schema/
|
||||||
│ │ │ └── index.js # Drizzle schema definitions
|
│ │ │ └── index.js # Drizzle schema definitions
|
||||||
│ │ ├── services/
|
│ │ ├── services/
|
||||||
│ │ │ ├── auth.service.js
|
│ │ │ ├── auth.service.js
|
||||||
|
│ │ │ ├── job-queue.service.js
|
||||||
│ │ │ └── lotw.service.js
|
│ │ │ └── lotw.service.js
|
||||||
│ │ └── index.js # Main server entry point
|
│ │ └── index.js # Main server entry point
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
"@elysiajs/static": "^1.4.7",
|
"@elysiajs/static": "^1.4.7",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"elysia": "^1.4.22"
|
"elysia": "^1.4.22",
|
||||||
|
"pino": "^10.2.0",
|
||||||
|
"pino-pretty": "^13.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'bun:sqlite';
|
|||||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import * as schema from '../db/schema/index.js';
|
import * as schema from '../db/schema/index.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
// Get the directory of this file (src/backend/config/)
|
// Get the directory of this file (src/backend/config/)
|
||||||
const configDir = import.meta.dir || new URL('.', import.meta.url).pathname;
|
const configDir = import.meta.dir || new URL('.', import.meta.url).pathname;
|
||||||
@@ -9,7 +10,7 @@ const configDir = import.meta.dir || new URL('.', import.meta.url).pathname;
|
|||||||
// Go up one level to get src/backend/, then to award.db
|
// Go up one level to get src/backend/, then to award.db
|
||||||
const dbPath = join(configDir, '..', 'award.db');
|
const dbPath = join(configDir, '..', 'award.db');
|
||||||
|
|
||||||
console.error('[Database] Using database at:', dbPath);
|
logger.debug('Database path', { dbPath });
|
||||||
|
|
||||||
// Create SQLite database connection
|
// Create SQLite database connection
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath);
|
||||||
|
|||||||
20
src/backend/config/logger.js
Normal file
20
src/backend/config/logger.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'),
|
||||||
|
transport: isDevelopment
|
||||||
|
? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default logger;
|
||||||
@@ -2,6 +2,7 @@ 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 { JWT_SECRET } from './config/jwt.js';
|
import { JWT_SECRET } from './config/jwt.js';
|
||||||
|
import logger from './config/logger.js';
|
||||||
import {
|
import {
|
||||||
registerUser,
|
registerUser,
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
@@ -229,24 +230,16 @@ const app = new Elysia()
|
|||||||
*/
|
*/
|
||||||
.post('/api/lotw/sync', async ({ user, set }) => {
|
.post('/api/lotw/sync', async ({ user, set }) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error('[/api/lotw/sync] No user found in request');
|
logger.warn('/api/lotw/sync: Unauthorized access attempt');
|
||||||
set.status = 401;
|
set.status = 401;
|
||||||
return { success: false, error: 'Unauthorized' };
|
return { success: false, error: 'Unauthorized' };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('[/api/lotw/sync] User authenticated:', user.id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get user's LoTW credentials from database
|
|
||||||
const userData = await getUserById(user.id);
|
const userData = await getUserById(user.id);
|
||||||
console.error('[/api/lotw/sync] User data from DB:', {
|
|
||||||
id: userData?.id,
|
|
||||||
lotwUsername: userData?.lotwUsername ? '***' : null,
|
|
||||||
hasPassword: !!userData?.lotwPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userData || !userData.lotwUsername || !userData.lotwPassword) {
|
if (!userData || !userData.lotwUsername || !userData.lotwPassword) {
|
||||||
console.error('[/api/lotw/sync] Missing LoTW credentials');
|
logger.debug('/api/lotw/sync: Missing LoTW credentials', { userId: user.id });
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -254,13 +247,11 @@ const app = new Elysia()
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue the sync job (enqueueJob will check for existing active jobs)
|
|
||||||
const result = await enqueueJob(user.id, 'lotw_sync', {
|
const result = await enqueueJob(user.id, 'lotw_sync', {
|
||||||
lotwUsername: userData.lotwUsername,
|
lotwUsername: userData.lotwUsername,
|
||||||
lotwPassword: userData.lotwPassword,
|
lotwPassword: userData.lotwPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If enqueueJob returned existingJob, format the response
|
|
||||||
if (!result.success && result.existingJob) {
|
if (!result.success && result.existingJob) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -271,7 +262,7 @@ const app = new Elysia()
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in /api/lotw/sync:', error);
|
logger.error('Error in /api/lotw/sync', { error: error.message });
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -488,7 +479,7 @@ const app = new Elysia()
|
|||||||
// Start server
|
// Start server
|
||||||
.listen(3001);
|
.listen(3001);
|
||||||
|
|
||||||
console.log(`🦊 Backend server running at http://localhost:${app.server?.port}`);
|
logger.info(`Backend server running`, { port: app.server?.port, url: `http://localhost:${app.server?.port}` });
|
||||||
console.log(`📡 API endpoints available at http://localhost:${app.server?.port}/api`);
|
logger.info(`API endpoints available`, { url: `http://localhost:${app.server?.port}/api` });
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import Database from 'bun:sqlite';
|
import logger from './config/logger.js';
|
||||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
import { execSync } from 'child_process';
|
||||||
import * as schema from './db/schema/index.js';
|
|
||||||
|
|
||||||
const sqlite = new Database('./award.db');
|
logger.info('Initializing database...');
|
||||||
const db = drizzle({
|
|
||||||
client: sqlite,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Creating database tables...');
|
|
||||||
|
|
||||||
// Use drizzle-kit to push the schema
|
|
||||||
// Since we don't have migrations, let's use the push command
|
|
||||||
const { execSync } = await import('child_process');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync('bun drizzle-kit push', {
|
execSync('bun drizzle-kit push', {
|
||||||
cwd: '/Users/joergdorgeist/Dev/award',
|
cwd: '/Users/joergdorgeist/Dev/award',
|
||||||
stdio: 'inherit'
|
stdio: 'inherit'
|
||||||
});
|
});
|
||||||
console.log('✓ Database initialized successfully!');
|
logger.info('Database initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize database:', error);
|
logger.error('Failed to initialize database', { error: error.message });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
import { t } from 'elysia';
|
|
||||||
import {
|
|
||||||
registerUser,
|
|
||||||
authenticateUser,
|
|
||||||
getUserById,
|
|
||||||
updateLoTWCredentials,
|
|
||||||
} from '../services/auth.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication routes
|
|
||||||
* Provides endpoints for user registration, login, and profile management
|
|
||||||
* These routes will be added to the main app which already has authMiddleware
|
|
||||||
*/
|
|
||||||
export const authRoutes = (app) => {
|
|
||||||
console.error('authRoutes function called with app');
|
|
||||||
return app
|
|
||||||
/**
|
|
||||||
* POST /api/auth/register
|
|
||||||
* Register a new user
|
|
||||||
*/
|
|
||||||
.post(
|
|
||||||
'/api/auth/register',
|
|
||||||
async ({ body, jwt, set }) => {
|
|
||||||
try {
|
|
||||||
// Create user
|
|
||||||
const user = await registerUser(body);
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
const token = await jwt.sign({
|
|
||||||
userId: user.id,
|
|
||||||
email: user.email,
|
|
||||||
callsign: user.callsign,
|
|
||||||
});
|
|
||||||
|
|
||||||
set.status = 201;
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
set.status = 400;
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
email: t.String({
|
|
||||||
format: 'email',
|
|
||||||
error: 'Invalid email address',
|
|
||||||
}),
|
|
||||||
password: t.String({
|
|
||||||
minLength: 8,
|
|
||||||
error: 'Password must be at least 8 characters',
|
|
||||||
}),
|
|
||||||
callsign: t.String({
|
|
||||||
minLength: 3,
|
|
||||||
maxLength: 10,
|
|
||||||
error: 'Callsign must be 3-10 characters',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/auth/login
|
|
||||||
* Authenticate user and return JWT token
|
|
||||||
*/
|
|
||||||
.post(
|
|
||||||
'/api/auth/login',
|
|
||||||
async ({ body, jwt, set }) => {
|
|
||||||
try {
|
|
||||||
// Authenticate user
|
|
||||||
const user = await authenticateUser(body.email, body.password);
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
const token = await jwt.sign({
|
|
||||||
userId: user.id,
|
|
||||||
email: user.email,
|
|
||||||
callsign: user.callsign,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
set.status = 401;
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid email or password',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
email: t.String({ format: 'email' }),
|
|
||||||
password: t.String(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/auth/me
|
|
||||||
* Get current user profile (requires authentication)
|
|
||||||
*/
|
|
||||||
.get('/api/auth/me', async ({ user, set }) => {
|
|
||||||
console.error('/me endpoint called, user:', user);
|
|
||||||
if (!user) {
|
|
||||||
console.error('No user in context - returning 401');
|
|
||||||
set.status = 401;
|
|
||||||
return { success: false, error: 'Unauthorized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get full user data from database
|
|
||||||
const userData = await getUserById(user.id);
|
|
||||||
if (!userData) {
|
|
||||||
set.status = 404;
|
|
||||||
return { success: false, error: 'User not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
user: userData,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/auth/lotw-credentials
|
|
||||||
* Update LoTW credentials (requires authentication)
|
|
||||||
*/
|
|
||||||
.put(
|
|
||||||
'/api/auth/lotw-credentials',
|
|
||||||
async ({ user, body, set }) => {
|
|
||||||
if (!user) {
|
|
||||||
set.status = 401;
|
|
||||||
return { success: false, error: 'Unauthorized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateLoTWCredentials(user.id, body.lotwUsername, body.lotwPassword);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'LoTW credentials updated successfully',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
set.status = 500;
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to update LoTW credentials',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: t.Object({
|
|
||||||
lotwUsername: t.String(),
|
|
||||||
lotwPassword: t.String(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,7 @@ const SALT_ROUNDS = 10;
|
|||||||
* @param {string} password - Plain text password
|
* @param {string} password - Plain text password
|
||||||
* @returns {Promise<string>} Hashed password
|
* @returns {Promise<string>} Hashed password
|
||||||
*/
|
*/
|
||||||
export async function hashPassword(password) {
|
async function hashPassword(password) {
|
||||||
return bcrypt.hash(password, SALT_ROUNDS);
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export async function hashPassword(password) {
|
|||||||
* @param {string} hash - Hashed password
|
* @param {string} hash - Hashed password
|
||||||
* @returns {Promise<boolean>} True if password matches
|
* @returns {Promise<boolean>} True if password matches
|
||||||
*/
|
*/
|
||||||
export async function verifyPassword(password, hash) {
|
async function verifyPassword(password, hash) {
|
||||||
return bcrypt.compare(password, hash);
|
return bcrypt.compare(password, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +35,11 @@ export async function verifyPassword(password, hash) {
|
|||||||
*/
|
*/
|
||||||
export async function registerUser({ email, password, callsign }) {
|
export async function registerUser({ email, password, callsign }) {
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await db
|
const [existingUser] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email))
|
.where(eq(users.email, email))
|
||||||
.get();
|
.limit(1);
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new Error('Email already registered');
|
throw new Error('Email already registered');
|
||||||
@@ -72,11 +72,11 @@ export async function registerUser({ email, password, callsign }) {
|
|||||||
*/
|
*/
|
||||||
export async function authenticateUser(email, password) {
|
export async function authenticateUser(email, password) {
|
||||||
// Find user by email
|
// Find user by email
|
||||||
const user = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email))
|
.where(eq(users.email, email))
|
||||||
.get();
|
.limit(1);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Invalid email or password');
|
throw new Error('Invalid email or password');
|
||||||
@@ -99,11 +99,11 @@ export async function authenticateUser(email, password) {
|
|||||||
* @returns {Promise<Object|null>} User object (without password) or null
|
* @returns {Promise<Object|null>} User object (without password) or null
|
||||||
*/
|
*/
|
||||||
export async function getUserById(userId) {
|
export async function getUserById(userId) {
|
||||||
const user = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
.get();
|
.limit(1);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { syncJobs } from '../db/schema/index.js';
|
import { syncJobs } from '../db/schema/index.js';
|
||||||
import { eq, and, desc, or, lt } from 'drizzle-orm';
|
import { eq, and, desc, or, lt } from 'drizzle-orm';
|
||||||
|
import logger from '../config/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background Job Queue Service
|
* Background Job Queue Service
|
||||||
@@ -43,12 +44,12 @@ export function registerProcessor(type, processor) {
|
|||||||
* @returns {Promise<Object>} Job object with ID
|
* @returns {Promise<Object>} Job object with ID
|
||||||
*/
|
*/
|
||||||
export async function enqueueJob(userId, type, data = {}) {
|
export async function enqueueJob(userId, type, data = {}) {
|
||||||
console.error('[enqueueJob] Starting job enqueue:', { userId, type, hasData: !!data });
|
logger.debug('Enqueueing job', { userId, type });
|
||||||
|
|
||||||
// Check for existing active job of same type for this user
|
// Check for existing active job of same type for this user
|
||||||
const existingJob = await getUserActiveJob(userId, type);
|
const existingJob = await getUserActiveJob(userId, type);
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
console.error('[enqueueJob] Found existing active job:', existingJob.id);
|
logger.debug('Existing active job found', { jobId: existingJob.id });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `A ${type} job is already running or pending for this user`,
|
error: `A ${type} job is already running or pending for this user`,
|
||||||
@@ -57,7 +58,6 @@ export async function enqueueJob(userId, type, data = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create job record
|
// Create job record
|
||||||
console.error('[enqueueJob] Creating job record in database...');
|
|
||||||
const [job] = await db
|
const [job] = await db
|
||||||
.insert(syncJobs)
|
.insert(syncJobs)
|
||||||
.values({
|
.values({
|
||||||
@@ -68,11 +68,11 @@ export async function enqueueJob(userId, type, data = {}) {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
console.error('[enqueueJob] Job created:', job.id);
|
logger.info('Job created', { jobId: job.id, type, userId });
|
||||||
|
|
||||||
// Start processing asynchronously (don't await)
|
// Start processing asynchronously (don't await)
|
||||||
processJobAsync(job.id, userId, type, data).catch((error) => {
|
processJobAsync(job.id, userId, type, data).catch((error) => {
|
||||||
console.error(`[enqueueJob] Error processing job ${job.id}:`, error);
|
logger.error(`Job processing error`, { jobId: job.id, error: error.message });
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -145,7 +145,7 @@ async function processJobAsync(jobId, userId, type, data) {
|
|||||||
* @param {number} jobId - Job ID
|
* @param {number} jobId - Job ID
|
||||||
* @param {Object} updates - Fields to update
|
* @param {Object} updates - Fields to update
|
||||||
*/
|
*/
|
||||||
export async function updateJob(jobId, updates) {
|
async function updateJob(jobId, updates) {
|
||||||
await db.update(syncJobs).set(updates).where(eq(syncJobs.id, jobId));
|
await db.update(syncJobs).set(updates).where(eq(syncJobs.id, jobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ export async function updateJob(jobId, updates) {
|
|||||||
* @param {number} jobId - Job ID
|
* @param {number} jobId - Job ID
|
||||||
* @returns {Promise<Object|null>} Job object or null
|
* @returns {Promise<Object|null>} Job object or null
|
||||||
*/
|
*/
|
||||||
export async function getJob(jobId) {
|
async function getJob(jobId) {
|
||||||
const [job] = await db.select().from(syncJobs).where(eq(syncJobs.id, jobId)).limit(1);
|
const [job] = await db.select().from(syncJobs).where(eq(syncJobs.id, jobId)).limit(1);
|
||||||
return job || null;
|
return job || null;
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ export async function getJobStatus(jobId) {
|
|||||||
try {
|
try {
|
||||||
parsedResult = JSON.parse(job.result);
|
parsedResult = JSON.parse(job.result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse job result:', e);
|
logger.warn('Failed to parse job result', { jobId, error: e.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,8 +198,6 @@ export async function getJobStatus(jobId) {
|
|||||||
* @returns {Promise<Object|null>} Active job or null
|
* @returns {Promise<Object|null>} Active job or null
|
||||||
*/
|
*/
|
||||||
export async function getUserActiveJob(userId, type = null) {
|
export async function getUserActiveJob(userId, type = null) {
|
||||||
console.error('[getUserActiveJob] Querying for active job:', { userId, type });
|
|
||||||
|
|
||||||
// Build the where clause properly with and() and or()
|
// Build the where clause properly with and() and or()
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(syncJobs.userId, userId),
|
eq(syncJobs.userId, userId),
|
||||||
@@ -213,7 +211,6 @@ export async function getUserActiveJob(userId, type = null) {
|
|||||||
conditions.push(eq(syncJobs.type, type));
|
conditions.push(eq(syncJobs.type, type));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const [job] = await db
|
const [job] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(syncJobs)
|
.from(syncJobs)
|
||||||
@@ -221,12 +218,7 @@ export async function getUserActiveJob(userId, type = null) {
|
|||||||
.orderBy(desc(syncJobs.createdAt))
|
.orderBy(desc(syncJobs.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
console.error('[getUserActiveJob] Result:', job ? `Found job ${job.id}` : 'No active job');
|
|
||||||
return job || null;
|
return job || null;
|
||||||
} catch (error) {
|
|
||||||
console.error('[getUserActiveJob] Database error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,6 +276,7 @@ export async function cleanupOldJobs(daysOld = 7) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.info('Cleaned up old jobs', { count: result, daysOld });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { qsos } from '../db/schema/index.js';
|
import { qsos } from '../db/schema/index.js';
|
||||||
import { max, sql } from 'drizzle-orm';
|
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
||||||
import { registerProcessor, updateJobProgress } from './job-queue.service.js';
|
import { registerProcessor, updateJobProgress } from './job-queue.service.js';
|
||||||
|
import logger from '../config/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoTW (Logbook of the World) Service
|
* LoTW (Logbook of the World) Service
|
||||||
@@ -9,41 +10,25 @@ import { registerProcessor, updateJobProgress } from './job-queue.service.js';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Wavelog-compatible constants
|
// Wavelog-compatible constants
|
||||||
const LOTW_CONNECT_TIMEOUT = 30; // CURLOPT_CONNECTTIMEOUT from Wavelog
|
const LOTW_CONNECT_TIMEOUT = 30;
|
||||||
|
|
||||||
// Configuration for long-polling
|
// Configuration for long-polling
|
||||||
const POLLING_CONFIG = {
|
const POLLING_CONFIG = {
|
||||||
maxRetries: 30, // Maximum number of retry attempts
|
maxRetries: 30,
|
||||||
retryDelay: 10000, // Delay between retries in ms (10 seconds)
|
retryDelay: 10000,
|
||||||
requestTimeout: 60000, // Timeout for individual requests in ms (1 minute)
|
requestTimeout: 60000,
|
||||||
maxTotalTime: 600000, // Maximum total time to wait in ms (10 minutes)
|
maxTotalTime: 600000,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if LoTW response indicates the report is still being prepared
|
* Check if LoTW response indicates the report is still being prepared
|
||||||
* @param {string} responseData - The response text from LoTW
|
|
||||||
* @returns {boolean} True if report is still pending
|
|
||||||
*/
|
*/
|
||||||
function isReportPending(responseData) {
|
function isReportPending(responseData) {
|
||||||
const trimmed = responseData.trim().toLowerCase();
|
const trimmed = responseData.trim().toLowerCase();
|
||||||
|
|
||||||
// LoTW returns various messages when report is not ready:
|
if (trimmed.length < 100) return true;
|
||||||
// - Empty responses
|
if (trimmed.includes('<html>') || trimmed.includes('<!doctype html>')) return true;
|
||||||
// - "Report is being prepared" or similar messages
|
|
||||||
// - HTML error pages
|
|
||||||
// - Very short responses that aren't valid ADIF
|
|
||||||
|
|
||||||
// Check for empty or very short responses
|
|
||||||
if (trimmed.length < 100) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for HTML responses (error pages)
|
|
||||||
if (trimmed.includes('<html>') || trimmed.includes('<!doctype html>')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for common "not ready" messages
|
|
||||||
const pendingMessages = [
|
const pendingMessages = [
|
||||||
'report is being prepared',
|
'report is being prepared',
|
||||||
'your report is being generated',
|
'your report is being generated',
|
||||||
@@ -54,12 +39,9 @@ function isReportPending(responseData) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const msg of pendingMessages) {
|
for (const msg of pendingMessages) {
|
||||||
if (trimmed.includes(msg)) {
|
if (trimmed.includes(msg)) return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it looks like valid ADIF data (should start with <ADIF_VER: or have <qso_date)
|
|
||||||
const hasAdifHeader = trimmed.includes('<adif_ver:') ||
|
const hasAdifHeader = trimmed.includes('<adif_ver:') ||
|
||||||
trimmed.includes('<qso_date') ||
|
trimmed.includes('<qso_date') ||
|
||||||
trimmed.includes('<call:') ||
|
trimmed.includes('<call:') ||
|
||||||
@@ -68,79 +50,57 @@ function isReportPending(responseData) {
|
|||||||
return !hasAdifHeader;
|
return !hasAdifHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
* Sleep/delay utility
|
|
||||||
* @param {number} ms - Milliseconds to sleep
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch QSOs from LoTW with long-polling support
|
* Fetch QSOs from LoTW with long-polling support
|
||||||
* @param {string} lotwUsername - LoTW username
|
|
||||||
* @param {string} lotwPassword - LoTW password
|
|
||||||
* @param {Date} sinceDate - Only fetch QSOs since this date
|
|
||||||
* @returns {Promise<Array>} Array of QSO objects
|
|
||||||
*/
|
*/
|
||||||
export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||||
// LoTW report URL
|
|
||||||
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
|
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
|
||||||
|
|
||||||
// Build query parameters - qso_query=1 is REQUIRED to get QSO records!
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
login: lotwUsername,
|
login: lotwUsername,
|
||||||
password: lotwPassword,
|
password: lotwPassword,
|
||||||
qso_query: '1', // REQUIRED: Without this, no QSO records are returned
|
qso_query: '1',
|
||||||
qso_qsl: 'yes', // Only get QSOs with QSLs (confirmed)
|
qso_qsl: 'yes',
|
||||||
qso_qsldetail: 'yes', // Include QSL details (station location)
|
qso_qsldetail: 'yes',
|
||||||
qso_mydetail: 'yes', // Include my station details
|
qso_mydetail: 'yes',
|
||||||
qso_withown: 'yes', // Include own callsign
|
qso_withown: 'yes',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add date filter - only add qso_qslsince if we have a last QSL date
|
|
||||||
// For first sync (no QSOs in DB), don't filter by date to get ALL QSOs
|
|
||||||
if (sinceDate) {
|
if (sinceDate) {
|
||||||
const dateStr = sinceDate.toISOString().split('T')[0];
|
const dateStr = sinceDate.toISOString().split('T')[0];
|
||||||
params.append('qso_qslsince', dateStr);
|
params.append('qso_qslsince', dateStr);
|
||||||
console.error('Date filter:', dateStr, '(Incremental sync since last QSL date)');
|
logger.debug('Incremental sync since', { date: dateStr });
|
||||||
} else {
|
} else {
|
||||||
console.error('No date filter - fetching ALL QSOs (first sync)');
|
logger.debug('Full sync - fetching all QSOs');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullUrl = `${url}?${params.toString()}`;
|
const fullUrl = `${url}?${params.toString()}`;
|
||||||
|
logger.debug('Fetching from LoTW', { url: fullUrl.replace(/password=[^&]+/, 'password=***') });
|
||||||
console.error('Fetching from LoTW:', fullUrl.replace(/password=[^&]+/, 'password=***'));
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Long-polling loop
|
|
||||||
for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) {
|
for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) {
|
||||||
try {
|
|
||||||
// Check if we've exceeded max total time
|
|
||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
if (elapsed > POLLING_CONFIG.maxTotalTime) {
|
if (elapsed > POLLING_CONFIG.maxTotalTime) {
|
||||||
throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`);
|
throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
console.error(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries} (elapsed: ${Math.round(elapsed / 1000)}s)`);
|
logger.debug(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries}`, { elapsed: Math.round(elapsed / 1000) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make request with timeout
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), POLLING_CONFIG.requestTimeout);
|
const timeoutId = setTimeout(() => controller.abort(), POLLING_CONFIG.requestTimeout);
|
||||||
|
|
||||||
const response = await fetch(fullUrl, { signal: controller.signal });
|
const response = await fetch(fullUrl, { signal: controller.signal });
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
// Handle HTTP errors
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 503) {
|
if (response.status === 503) {
|
||||||
// Service unavailable - might be temporary, retry
|
logger.warn('LoTW returned 503, retrying...');
|
||||||
console.error('LoTW returned 503 (Service Unavailable), waiting before retry...');
|
|
||||||
await sleep(POLLING_CONFIG.retryDelay);
|
await sleep(POLLING_CONFIG.retryDelay);
|
||||||
continue;
|
continue;
|
||||||
} else if (response.status === 401) {
|
} else if (response.status === 401) {
|
||||||
@@ -148,62 +108,48 @@ export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate =
|
|||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
throw new Error('LoTW service not found (404). The LoTW API URL may have changed.');
|
throw new Error('LoTW service not found (404). The LoTW API URL may have changed.');
|
||||||
} else {
|
} else {
|
||||||
// Other errors - log but retry
|
logger.warn(`LoTW returned ${response.status}, retrying...`);
|
||||||
console.error(`LoTW returned ${response.status} ${response.statusText}, waiting before retry...`);
|
|
||||||
await sleep(POLLING_CONFIG.retryDelay);
|
await sleep(POLLING_CONFIG.retryDelay);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get response text
|
|
||||||
const adifData = await response.text();
|
const adifData = await response.text();
|
||||||
console.error(`Response length: ${adifData.length} bytes`);
|
|
||||||
|
|
||||||
// Wavelog: Validate response for credential errors
|
|
||||||
if (adifData.toLowerCase().includes('username/password incorrect')) {
|
if (adifData.toLowerCase().includes('username/password incorrect')) {
|
||||||
throw new Error('Username/password incorrect');
|
throw new Error('Username/password incorrect');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wavelog: Check if file starts with expected header
|
|
||||||
const header = adifData.trim().substring(0, 39).toLowerCase();
|
const header = adifData.trim().substring(0, 39).toLowerCase();
|
||||||
if (!header.includes('arrl logbook of the world')) {
|
if (!header.includes('arrl logbook of the world')) {
|
||||||
// This might be because the report is still pending
|
|
||||||
if (isReportPending(adifData)) {
|
if (isReportPending(adifData)) {
|
||||||
console.error('LoTW report is still being prepared, waiting...', adifData.substring(0, 100));
|
logger.debug('LoTW report still being prepared, waiting...');
|
||||||
await sleep(POLLING_CONFIG.retryDelay);
|
await sleep(POLLING_CONFIG.retryDelay);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error('Downloaded LoTW report is invalid. Check your credentials.');
|
throw new Error('Downloaded LoTW report is invalid. Check your credentials.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// We have valid data!
|
logger.info('LoTW report downloaded successfully', { size: adifData.length });
|
||||||
console.error('LoTW report ready, parsing ADIF data...');
|
|
||||||
console.error('ADIF preview:', adifData.substring(0, 200));
|
|
||||||
|
|
||||||
// Parse ADIF format
|
|
||||||
const qsos = parseADIF(adifData);
|
const qsos = parseADIF(adifData);
|
||||||
console.error(`Successfully parsed ${qsos.length} QSOs from LoTW`);
|
logger.info('Parsed QSOs from LoTW', { count: qsos.length });
|
||||||
|
|
||||||
return qsos;
|
return qsos;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
|
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
console.error(`Request timeout on attempt ${attempt + 1}, retrying...`);
|
logger.debug('Request timeout, retrying...');
|
||||||
await sleep(POLLING_CONFIG.retryDelay);
|
await sleep(POLLING_CONFIG.retryDelay);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-throw credential/auth errors immediately
|
|
||||||
if (error.message.includes('credentials') || error.message.includes('401') || error.message.includes('404')) {
|
if (error.message.includes('credentials') || error.message.includes('401') || error.message.includes('404')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other errors, log and retry if we haven't exhausted retries
|
|
||||||
if (attempt < POLLING_CONFIG.maxRetries - 1) {
|
if (attempt < POLLING_CONFIG.maxRetries - 1) {
|
||||||
console.error(`Error on attempt ${attempt + 1}: ${error.message}`);
|
logger.warn(`Error on attempt ${attempt + 1}`, { error: error.message });
|
||||||
console.error('Retrying...');
|
|
||||||
await sleep(POLLING_CONFIG.retryDelay);
|
await sleep(POLLING_CONFIG.retryDelay);
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
@@ -212,73 +158,47 @@ export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, we exhausted all retries
|
|
||||||
const totalTime = Math.round((Date.now() - startTime) / 1000);
|
const totalTime = Math.round((Date.now() - startTime) / 1000);
|
||||||
throw new Error(`LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`);
|
throw new Error(`LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse ADIF (Amateur Data Interchange Format) data
|
* Parse ADIF (Amateur Data Interchange Format) data
|
||||||
* @param {string} adifData - Raw ADIF text data
|
|
||||||
* @returns {Array<Array>} Array of QSOs (each QSO is an array of field objects)
|
|
||||||
*/
|
*/
|
||||||
function parseADIF(adifData) {
|
function parseADIF(adifData) {
|
||||||
const qsos = [];
|
const qsos = [];
|
||||||
const records = adifData.split('<eor>');
|
const records = adifData.split('<eor>');
|
||||||
|
|
||||||
console.error(`Total records after splitting by <eor>: ${records.length}`);
|
for (const record of records) {
|
||||||
|
|
||||||
for (let i = 0; i < records.length; i++) {
|
|
||||||
const record = records[i];
|
|
||||||
|
|
||||||
// Skip empty records or records that are just header info
|
|
||||||
if (!record.trim()) continue;
|
if (!record.trim()) continue;
|
||||||
if (record.trim().startsWith('<') && !record.includes('<CALL:') && !record.includes('<call:')) continue;
|
if (record.trim().startsWith('<') && !record.includes('<CALL:') && !record.includes('<call:')) continue;
|
||||||
|
|
||||||
const qso = {};
|
const qso = {};
|
||||||
|
|
||||||
// Match ADIF fields: <FIELDNAME:length[:type]>value
|
|
||||||
// Important: The 'type' part is optional, and field names can contain underscores
|
|
||||||
// We use the length parameter to extract exactly that many characters
|
|
||||||
const regex = /<([A-Z_]+):(\d+)(?::[A-Z]+)?>([\s\S])/gi;
|
const regex = /<([A-Z_]+):(\d+)(?::[A-Z]+)?>([\s\S])/gi;
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = regex.exec(record)) !== null) {
|
while ((match = regex.exec(record)) !== null) {
|
||||||
const [fullMatch, fieldName, lengthStr, firstChar] = match;
|
const [fullMatch, fieldName, lengthStr, firstChar] = match;
|
||||||
const length = parseInt(lengthStr, 10);
|
const length = parseInt(lengthStr, 10);
|
||||||
|
const valueStart = match.index + fullMatch.length - 1;
|
||||||
// Extract exactly 'length' characters starting from the position after the '>'
|
|
||||||
const valueStart = match.index + fullMatch.length - 1; // -1 because firstChar is already captured
|
|
||||||
const value = record.substring(valueStart, valueStart + length);
|
const value = record.substring(valueStart, valueStart + length);
|
||||||
|
|
||||||
qso[fieldName.toLowerCase()] = value.trim();
|
qso[fieldName.toLowerCase()] = value.trim();
|
||||||
|
|
||||||
// Move regex lastIndex to the end of the value so we can find the next tag
|
|
||||||
regex.lastIndex = valueStart + length;
|
regex.lastIndex = valueStart + length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only add if we have actual QSO data (has CALL or call field)
|
|
||||||
if (Object.keys(qso).length > 0 && (qso.call || qso.call)) {
|
if (Object.keys(qso).length > 0 && (qso.call || qso.call)) {
|
||||||
qsos.push(qso);
|
qsos.push(qso);
|
||||||
|
|
||||||
// Log first few QSOs for debugging
|
|
||||||
if (qsos.length <= 3) {
|
|
||||||
console.error(`Parsed QSO #${qsos.length}: ${qso.call} on ${qso.qso_date} ${qso.band} ${qso.mode}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`Total QSOs parsed: ${qsos.length}`);
|
|
||||||
return qsos;
|
return qsos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert ADIF QSO to database format
|
* Convert ADIF QSO to database format
|
||||||
* @param {Object} adifQSO - QSO object from ADIF parser
|
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @returns {Object} Database-ready QSO object
|
|
||||||
*/
|
*/
|
||||||
export function convertQSODatabaseFormat(adifQSO, userId) {
|
function convertQSODatabaseFormat(adifQSO, userId) {
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
callsign: adifQSO.call || '',
|
callsign: adifQSO.call || '',
|
||||||
@@ -303,66 +223,29 @@ export function convertQSODatabaseFormat(adifQSO, userId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize band name
|
|
||||||
* @param {string} band - Band from ADIF
|
|
||||||
* @returns {string|null} Normalized band
|
|
||||||
*/
|
|
||||||
function normalizeBand(band) {
|
function normalizeBand(band) {
|
||||||
if (!band) return null;
|
if (!band) return null;
|
||||||
|
|
||||||
const bandMap = {
|
const bandMap = {
|
||||||
'160m': '160m',
|
'160m': '160m', '80m': '80m', '60m': '60m', '40m': '40m',
|
||||||
'80m': '80m',
|
'30m': '30m', '20m': '20m', '17m': '17m', '15m': '15m',
|
||||||
'60m': '60m',
|
'12m': '12m', '10m': '10m', '6m': '6m', '4m': '4m',
|
||||||
'40m': '40m',
|
'2m': '2m', '1.25m': '1.25m', '70cm': '70cm', '33cm': '33cm',
|
||||||
'30m': '30m',
|
'23cm': '23cm', '13cm': '13cm', '9cm': '9cm', '6cm': '6cm',
|
||||||
'20m': '20m',
|
'3cm': '3cm', '1.2cm': '1.2cm', 'mm': 'mm',
|
||||||
'17m': '17m',
|
|
||||||
'15m': '15m',
|
|
||||||
'12m': '12m',
|
|
||||||
'10m': '10m',
|
|
||||||
'6m': '6m',
|
|
||||||
'4m': '4m',
|
|
||||||
'2m': '2m',
|
|
||||||
'1.25m': '1.25m',
|
|
||||||
'70cm': '70cm',
|
|
||||||
'33cm': '33cm',
|
|
||||||
'23cm': '23cm',
|
|
||||||
'13cm': '13cm',
|
|
||||||
'9cm': '9cm',
|
|
||||||
'6cm': '6cm',
|
|
||||||
'3cm': '3cm',
|
|
||||||
'1.2cm': '1.2cm',
|
|
||||||
'mm': 'mm',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return bandMap[band.toLowerCase()] || band;
|
return bandMap[band.toLowerCase()] || band;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize mode name
|
|
||||||
* @param {string} mode - Mode from ADIF
|
|
||||||
* @returns {string} Normalized mode
|
|
||||||
*/
|
|
||||||
function normalizeMode(mode) {
|
function normalizeMode(mode) {
|
||||||
if (!mode) return '';
|
if (!mode) return '';
|
||||||
|
|
||||||
const modeMap = {
|
const modeMap = {
|
||||||
'cw': 'CW',
|
'cw': 'CW', 'ssb': 'SSB', 'am': 'AM', 'fm': 'FM',
|
||||||
'ssb': 'SSB',
|
'rtty': 'RTTY', 'psk31': 'PSK31', 'psk63': 'PSK63',
|
||||||
'am': 'AM',
|
'ft8': 'FT8', 'ft4': 'FT4', 'jt65': 'JT65', 'jt9': 'JT9',
|
||||||
'fm': 'FM',
|
'js8': 'JS8', 'mfsk': 'MFSK', 'olivia': 'OLIVIA',
|
||||||
'rtty': 'RTTY',
|
|
||||||
'psk31': 'PSK31',
|
|
||||||
'psk63': 'PSK63',
|
|
||||||
'ft8': 'FT8',
|
|
||||||
'ft4': 'FT4',
|
|
||||||
'jt65': 'JT65',
|
|
||||||
'jt9': 'JT9',
|
|
||||||
'js8': 'JS8',
|
|
||||||
'mfsk': 'MFSK',
|
|
||||||
' Olivia': 'OLIVIA',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalized = modeMap[mode.toLowerCase()];
|
const normalized = modeMap[mode.toLowerCase()];
|
||||||
@@ -371,41 +254,22 @@ function normalizeMode(mode) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync QSOs from LoTW to database
|
* Sync QSOs from LoTW to database
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @param {string} lotwUsername - LoTW username
|
|
||||||
* @param {string} lotwPassword - LoTW password
|
|
||||||
* @param {Date} sinceDate - Only sync QSOs since this date
|
|
||||||
* @returns {Promise<Object>} Sync result with counts
|
|
||||||
*/
|
*/
|
||||||
export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) {
|
async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) {
|
||||||
try {
|
|
||||||
// Fetch QSOs from LoTW
|
|
||||||
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
|
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
|
||||||
|
|
||||||
if (!adifQSOs || adifQSOs.length === 0) {
|
if (!adifQSOs || adifQSOs.length === 0) {
|
||||||
return {
|
return { success: true, total: 0, added: 0, updated: 0, message: 'No QSOs found in LoTW' };
|
||||||
success: true,
|
|
||||||
total: 0,
|
|
||||||
added: 0,
|
|
||||||
updated: 0,
|
|
||||||
message: 'No QSOs found in LoTW',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
// Process each QSO
|
|
||||||
for (const qsoData of adifQSOs) {
|
for (const qsoData of adifQSOs) {
|
||||||
try {
|
try {
|
||||||
console.error('Raw ADIF QSO data:', JSON.stringify(qsoData));
|
|
||||||
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
|
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
|
||||||
console.error('Converted QSO:', JSON.stringify(dbQSO));
|
|
||||||
console.error('Processing QSO:', dbQSO.callsign, dbQSO.qsoDate, dbQSO.band, dbQSO.mode);
|
|
||||||
|
|
||||||
// Check if QSO already exists (by callsign, date, time, band, mode)
|
|
||||||
const { eq } = await import('drizzle-orm');
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(qsos)
|
.from(qsos)
|
||||||
@@ -416,14 +280,7 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
.where(eq(qsos.mode, dbQSO.mode))
|
.where(eq(qsos.mode, dbQSO.mode))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
console.error('Existing QSOs found:', existing.length);
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
console.error('Existing QSO:', JSON.stringify(existing[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
// Update existing QSO
|
|
||||||
console.error('Updating existing QSO');
|
|
||||||
await db
|
await db
|
||||||
.update(qsos)
|
.update(qsos)
|
||||||
.set({
|
.set({
|
||||||
@@ -434,27 +291,17 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
.where(eq(qsos.id, existing[0].id));
|
.where(eq(qsos.id, existing[0].id));
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
} else {
|
} else {
|
||||||
// Insert new QSO
|
await db.insert(qsos).values(dbQSO);
|
||||||
console.error('Inserting new QSO with data:', JSON.stringify(dbQSO));
|
|
||||||
try {
|
|
||||||
const result = await db.insert(qsos).values(dbQSO);
|
|
||||||
console.error('Insert result:', result);
|
|
||||||
addedCount++;
|
addedCount++;
|
||||||
} catch (insertError) {
|
|
||||||
console.error('Insert failed:', insertError.message);
|
|
||||||
console.error('Insert error details:', insertError);
|
|
||||||
throw insertError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ERROR processing QSO:', error);
|
logger.error('Error processing QSO', { error: error.message, qso: qsoData });
|
||||||
errors.push({
|
errors.push({ qso: qsoData, error: error.message });
|
||||||
qso: qsoData,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
total: adifQSOs.length,
|
total: adifQSOs.length,
|
||||||
@@ -462,48 +309,25 @@ export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = n
|
|||||||
updated: updatedCount,
|
updated: updatedCount,
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`LoTW sync failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get QSOs for a user with pagination
|
* Get QSOs for a user with pagination
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @param {Object} filters - Query filters
|
|
||||||
* @param {Object} options - Pagination options { page, limit }
|
|
||||||
* @returns {Promise<Object>} Paginated QSOs
|
|
||||||
*/
|
*/
|
||||||
export async function getUserQSOs(userId, filters = {}, options = {}) {
|
export async function getUserQSOs(userId, filters = {}, options = {}) {
|
||||||
const { eq, and, desc, sql } = await import('drizzle-orm');
|
|
||||||
|
|
||||||
const { page = 1, limit = 100 } = options;
|
const { page = 1, limit = 100 } = options;
|
||||||
|
|
||||||
console.error('getUserQSOs called with userId:', userId, 'filters:', filters, 'page:', page, 'limit:', limit);
|
|
||||||
|
|
||||||
// Build where conditions
|
|
||||||
const conditions = [eq(qsos.userId, userId)];
|
const conditions = [eq(qsos.userId, userId)];
|
||||||
|
|
||||||
if (filters.band) {
|
if (filters.band) conditions.push(eq(qsos.band, filters.band));
|
||||||
conditions.push(eq(qsos.band, filters.band));
|
if (filters.mode) conditions.push(eq(qsos.mode, filters.mode));
|
||||||
}
|
if (filters.confirmed) conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
||||||
|
|
||||||
if (filters.mode) {
|
|
||||||
conditions.push(eq(qsos.mode, filters.mode));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.confirmed) {
|
|
||||||
conditions.push(eq(qsos.lotwQslRstatus, 'Y'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count for pagination
|
|
||||||
const allResults = await db.select().from(qsos).where(and(...conditions));
|
const allResults = await db.select().from(qsos).where(and(...conditions));
|
||||||
const totalCount = allResults.length;
|
const totalCount = allResults.length;
|
||||||
|
|
||||||
// Calculate offset
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Get paginated results
|
|
||||||
const results = await db
|
const results = await db
|
||||||
.select()
|
.select()
|
||||||
.from(qsos)
|
.from(qsos)
|
||||||
@@ -512,8 +336,6 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
console.error('getUserQSOs returning', results.length, 'QSOs (page', page, 'of', Math.ceil(totalCount / limit), ')');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
qsos: results,
|
qsos: results,
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -529,18 +351,11 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get QSO statistics for a user
|
* Get QSO statistics for a user
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @returns {Promise<Object>} Statistics object
|
|
||||||
*/
|
*/
|
||||||
export async function getQSOStats(userId) {
|
export async function getQSOStats(userId) {
|
||||||
const { eq } = await import('drizzle-orm');
|
|
||||||
const allQSOs = await db.select().from(qsos).where(eq(qsos.userId, userId));
|
const allQSOs = await db.select().from(qsos).where(eq(qsos.userId, userId));
|
||||||
|
|
||||||
console.error('getQSOStats called with userId:', userId, 'found', allQSOs.length, 'QSOs in database');
|
|
||||||
|
|
||||||
const confirmed = allQSOs.filter((q) => q.lotwQslRstatus === 'Y');
|
const confirmed = allQSOs.filter((q) => q.lotwQslRstatus === 'Y');
|
||||||
|
|
||||||
// Count unique entities
|
|
||||||
const uniqueEntities = new Set();
|
const uniqueEntities = new Set();
|
||||||
const uniqueBands = new Set();
|
const uniqueBands = new Set();
|
||||||
const uniqueModes = new Set();
|
const uniqueModes = new Set();
|
||||||
@@ -551,43 +366,28 @@ export async function getQSOStats(userId) {
|
|||||||
if (q.mode) uniqueModes.add(q.mode);
|
if (q.mode) uniqueModes.add(q.mode);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stats = {
|
return {
|
||||||
total: allQSOs.length,
|
total: allQSOs.length,
|
||||||
confirmed: confirmed.length,
|
confirmed: confirmed.length,
|
||||||
uniqueEntities: uniqueEntities.size,
|
uniqueEntities: uniqueEntities.size,
|
||||||
uniqueBands: uniqueBands.size,
|
uniqueBands: uniqueBands.size,
|
||||||
uniqueModes: uniqueModes.size,
|
uniqueModes: uniqueModes.size,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.error('getQSOStats returning:', stats);
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the date of the last LoTW QSL for a user
|
* Get the date of the last LoTW QSL for a user
|
||||||
* Used for qso_qslsince parameter to minimize downloads
|
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @returns {Promise<Date|null>} Last QSL date or null
|
|
||||||
*/
|
*/
|
||||||
export async function getLastLoTWQSLDate(userId) {
|
async function getLastLoTWQSLDate(userId) {
|
||||||
const { eq } = await import('drizzle-orm');
|
|
||||||
|
|
||||||
// Get the most recent lotwQslRdate for this user
|
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({ maxDate: max(qsos.lotwQslRdate) })
|
.select({ maxDate: max(qsos.lotwQslRdate) })
|
||||||
.from(qsos)
|
.from(qsos)
|
||||||
.where(eq(qsos.userId, userId));
|
.where(eq(qsos.userId, userId));
|
||||||
|
|
||||||
if (!result || !result.maxDate) {
|
if (!result || !result.maxDate) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ADIF date format (YYYYMMDD) to Date
|
|
||||||
const dateStr = result.maxDate;
|
const dateStr = result.maxDate;
|
||||||
if (!dateStr || dateStr === '') {
|
if (!dateStr || dateStr === '') return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = dateStr.substring(0, 4);
|
const year = dateStr.substring(0, 4);
|
||||||
const month = dateStr.substring(4, 6);
|
const month = dateStr.substring(4, 6);
|
||||||
@@ -596,79 +396,32 @@ export async function getLastLoTWQSLDate(userId) {
|
|||||||
return new Date(`${year}-${month}-${day}`);
|
return new Date(`${year}-${month}-${day}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate LoTW response following Wavelog logic
|
|
||||||
* @param {string} responseData - Response from LoTW
|
|
||||||
* @returns {Object} { valid: boolean, error?: string }
|
|
||||||
*/
|
|
||||||
function validateLoTWResponse(responseData) {
|
|
||||||
const trimmed = responseData.trim();
|
|
||||||
|
|
||||||
// Wavelog: Check for username/password incorrect
|
|
||||||
if (trimmed.toLowerCase().includes('username/password incorrect')) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Username/password incorrect',
|
|
||||||
shouldClearCredentials: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wavelog: Check if file starts with "ARRL Logbook of the World Status Report"
|
|
||||||
const header = trimmed.substring(0, 39).toLowerCase();
|
|
||||||
if (!header.includes('arrl logbook of the world')) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Downloaded LoTW report is invalid. File does not start with expected header.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoTW sync job processor for the job queue
|
* LoTW sync job processor for the job queue
|
||||||
* @param {number} jobId - Job ID
|
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @param {Object} data - Job data { lotwUsername, lotwPassword }
|
|
||||||
* @returns {Promise<Object>} Sync result
|
|
||||||
*/
|
*/
|
||||||
export async function syncQSOsForJob(jobId, userId, data) {
|
export async function syncQSOsForJob(jobId, userId, data) {
|
||||||
const { lotwUsername, lotwPassword } = data;
|
const { lotwUsername, lotwPassword } = data;
|
||||||
|
|
||||||
try {
|
|
||||||
// Update job progress: starting
|
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
message: 'Fetching QSOs from LoTW...',
|
message: 'Fetching QSOs from LoTW...',
|
||||||
step: 'fetch',
|
step: 'fetch',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get last LoTW QSL date for incremental sync
|
|
||||||
const lastQSLDate = await getLastLoTWQSLDate(userId);
|
const lastQSLDate = await getLastLoTWQSLDate(userId);
|
||||||
|
|
||||||
// If no QSOs exist, use a far past date to get ALL QSOs (first sync)
|
|
||||||
// Otherwise, use the last QSL date for incremental sync
|
|
||||||
const sinceDate = lastQSLDate || new Date('2000-01-01');
|
const sinceDate = lastQSLDate || new Date('2000-01-01');
|
||||||
|
|
||||||
if (lastQSLDate) {
|
if (lastQSLDate) {
|
||||||
console.error(`[Job ${jobId}] Incremental sync since ${sinceDate.toISOString().split('T')[0]}`);
|
logger.info(`Job ${jobId}: Incremental sync`, { since: sinceDate.toISOString().split('T')[0] });
|
||||||
} else {
|
} else {
|
||||||
console.error(`[Job ${jobId}] Full sync - fetching ALL QSOs since 2000-01-01`);
|
logger.info(`Job ${jobId}: Full sync`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from LoTW
|
|
||||||
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
|
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
|
||||||
|
|
||||||
if (!adifQSOs || adifQSOs.length === 0) {
|
if (!adifQSOs || adifQSOs.length === 0) {
|
||||||
return {
|
return { success: true, total: 0, added: 0, updated: 0, message: 'No QSOs found in LoTW' };
|
||||||
success: true,
|
|
||||||
total: 0,
|
|
||||||
added: 0,
|
|
||||||
updated: 0,
|
|
||||||
message: 'No QSOs found in LoTW',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update job progress: processing
|
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
message: `Processing ${adifQSOs.length} QSOs...`,
|
message: `Processing ${adifQSOs.length} QSOs...`,
|
||||||
step: 'process',
|
step: 'process',
|
||||||
@@ -680,15 +433,12 @@ export async function syncQSOsForJob(jobId, userId, data) {
|
|||||||
let updatedCount = 0;
|
let updatedCount = 0;
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
// Process each QSO
|
|
||||||
for (let i = 0; i < adifQSOs.length; i++) {
|
for (let i = 0; i < adifQSOs.length; i++) {
|
||||||
const qsoData = adifQSOs[i];
|
const qsoData = adifQSOs[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
|
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
|
||||||
|
|
||||||
// Check if QSO already exists
|
|
||||||
const { eq, and } = await import('drizzle-orm');
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(qsos)
|
.from(qsos)
|
||||||
@@ -704,7 +454,6 @@ export async function syncQSOsForJob(jobId, userId, data) {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
// Update existing QSO
|
|
||||||
await db
|
await db
|
||||||
.update(qsos)
|
.update(qsos)
|
||||||
.set({
|
.set({
|
||||||
@@ -715,12 +464,10 @@ export async function syncQSOsForJob(jobId, userId, data) {
|
|||||||
.where(eq(qsos.id, existing[0].id));
|
.where(eq(qsos.id, existing[0].id));
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
} else {
|
} else {
|
||||||
// Insert new QSO
|
|
||||||
await db.insert(qsos).values(dbQSO);
|
await db.insert(qsos).values(dbQSO);
|
||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress every 10 QSOs
|
|
||||||
if ((i + 1) % 10 === 0) {
|
if ((i + 1) % 10 === 0) {
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
processed: i + 1,
|
processed: i + 1,
|
||||||
@@ -728,14 +475,13 @@ export async function syncQSOsForJob(jobId, userId, data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Job ${jobId}] ERROR processing QSO:`, error);
|
logger.error(`Job ${jobId}: Error processing QSO`, { error: error.message });
|
||||||
errors.push({
|
errors.push({ qso: qsoData, error: error.message });
|
||||||
qso: qsoData,
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`Job ${jobId} completed`, { total: adifQSOs.length, added: addedCount, updated: updatedCount });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
total: adifQSOs.length,
|
total: adifQSOs.length,
|
||||||
@@ -743,25 +489,13 @@ export async function syncQSOsForJob(jobId, userId, data) {
|
|||||||
updated: updatedCount,
|
updated: updatedCount,
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
// Check if it's a credential error
|
|
||||||
if (error.message.includes('Username/password incorrect')) {
|
|
||||||
throw new Error('Invalid LoTW credentials. Please check your username and password.');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all QSOs for a user
|
* Delete all QSOs for a user
|
||||||
* @param {number} userId - User ID
|
|
||||||
* @returns {Promise<number>} Number of QSOs deleted
|
|
||||||
*/
|
*/
|
||||||
export async function deleteQSOs(userId) {
|
export async function deleteQSOs(userId) {
|
||||||
const { eq } = await import('drizzle-orm');
|
|
||||||
|
|
||||||
const result = await db.delete(qsos).where(eq(qsos.userId, userId));
|
const result = await db.delete(qsos).where(eq(qsos.userId, userId));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { auth } from '$lib/stores.js';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -9,6 +10,21 @@
|
|||||||
|
|
||||||
{#if browser}
|
{#if browser}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
{#if $auth.user}
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<div class="nav-brand">
|
||||||
|
<span class="callsign">{$auth.user.callsign}</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/" class="nav-link">Dashboard</a>
|
||||||
|
<a href="/qsos" class="nav-link">QSOs</a>
|
||||||
|
<a href="/settings" class="nav-link">Settings</a>
|
||||||
|
<button on:click={auth.logout} class="nav-link logout-btn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
<main>
|
<main>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
@@ -42,6 +58,60 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand .callsign {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: white;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
color: #ff5252;
|
||||||
|
background-color: rgba(255, 107, 107, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user