diff --git a/README.md b/README.md index 1487314..92abec2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A web application for amateur radio operators to track QSOs (contacts) and award - **Framework**: Elysia.js - **Database**: SQLite with Drizzle ORM - **Authentication**: JWT tokens +- **Logging**: Pino with structured logging and timestamps ### Frontend - **Framework**: SvelteKit @@ -38,7 +39,8 @@ award/ │ ├── backend/ │ │ ├── config/ │ │ │ ├── database.js # Database connection -│ │ │ └── jwt.js # JWT configuration +│ │ │ ├── jwt.js # JWT configuration +│ │ │ └── logger.js # Pino logging configuration │ │ ├── db/ │ │ │ └── schema/ │ │ │ └── index.js # Database schema (users, qsos, sync_jobs) @@ -53,9 +55,11 @@ award/ │ │ │ ├── api.js # API client │ │ │ └── stores.js # Svelte stores (auth) │ │ └── routes/ -│ │ ├── +page.svelte # Main menu -│ │ ├── login/+page.svelte # Login page -│ │ ├── register/+page.svelte # Registration page +│ │ ├── +layout.svelte # Navigation bar & layout +│ │ ├── +page.svelte # Dashboard +│ │ ├── auth/ +│ │ │ ├── login/+page.svelte # Login page +│ │ │ └── register/+page.svelte # Registration page │ │ ├── qsos/+page.svelte # QSO log with pagination │ │ └── settings/+page.svelte # Settings & LoTW credentials │ └── package.json diff --git a/bun.lock b/bun.lock index 1cf70ab..5872535 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,8 @@ "bcrypt": "^6.0.0", "drizzle-orm": "^0.45.1", "elysia": "^1.4.22", + "pino": "^10.2.0", + "pino-pretty": "^13.1.3", }, "devDependencies": { "@libsql/client": "^0.17.0", @@ -117,6 +119,8 @@ "@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=="], "@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=="], + "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=="], "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=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "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=="], "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=="], "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=="], + "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-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=="], + "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-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=="], "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=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "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=="], "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=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "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=="], @@ -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=="], + "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=="], + "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=="], + "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=="], + "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-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=="], + "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=="], "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=="], + "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=="], "@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=="], diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 2db8c0c..3dcbb90 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -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 - **Authentication**: JWT tokens via `@elysiajs/jwt` - **Password Hashing**: bcrypt +- **Logging**: Pino - Structured logging with timestamps and log levels **Frontend:** - **Framework**: SvelteKit - Modern reactive framework @@ -118,7 +119,8 @@ Defines the database structure using Drizzle ORM schema builder. #### 4. Configuration (`src/backend/config/`) - **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 @@ -130,7 +132,14 @@ Defines the database structure using Drizzle ORM schema builder. - **`/qsos`**: QSO logbook with filtering and LoTW sync - **`/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`) - Centralized API communication @@ -172,13 +181,15 @@ award/ ├── src/ │ ├── backend/ # Backend server code │ │ ├── config/ -│ │ │ ├── constants.js -│ │ │ └── database.js +│ │ │ ├── database.js # Database connection +│ │ │ ├── jwt.js # JWT configuration +│ │ │ └── logger.js # Pino logging configuration │ │ ├── db/ │ │ │ └── schema/ │ │ │ └── index.js # Drizzle schema definitions │ │ ├── services/ │ │ │ ├── auth.service.js +│ │ │ ├── job-queue.service.js │ │ │ └── lotw.service.js │ │ └── index.js # Main server entry point │ │ diff --git a/package.json b/package.json index a37c9b8..7bda0b8 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "@elysiajs/static": "^1.4.7", "bcrypt": "^6.0.0", "drizzle-orm": "^0.45.1", - "elysia": "^1.4.22" + "elysia": "^1.4.22", + "pino": "^10.2.0", + "pino-pretty": "^13.1.3" } } diff --git a/src/backend/config/database.js b/src/backend/config/database.js index b6438a2..943f7ee 100644 --- a/src/backend/config/database.js +++ b/src/backend/config/database.js @@ -2,6 +2,7 @@ import Database from 'bun:sqlite'; import { drizzle } from 'drizzle-orm/bun-sqlite'; import * as schema from '../db/schema/index.js'; import { join } from 'path'; +import logger from './logger.js'; // Get the directory of this file (src/backend/config/) 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 const dbPath = join(configDir, '..', 'award.db'); -console.error('[Database] Using database at:', dbPath); +logger.debug('Database path', { dbPath }); // Create SQLite database connection const sqlite = new Database(dbPath); diff --git a/src/backend/config/logger.js b/src/backend/config/logger.js new file mode 100644 index 0000000..5576f82 --- /dev/null +++ b/src/backend/config/logger.js @@ -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; diff --git a/src/backend/index.js b/src/backend/index.js index 69c796b..d30454e 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -2,6 +2,7 @@ import { Elysia, t } from 'elysia'; import { cors } from '@elysiajs/cors'; import { jwt } from '@elysiajs/jwt'; import { JWT_SECRET } from './config/jwt.js'; +import logger from './config/logger.js'; import { registerUser, authenticateUser, @@ -229,24 +230,16 @@ const app = new Elysia() */ .post('/api/lotw/sync', async ({ user, set }) => { if (!user) { - console.error('[/api/lotw/sync] No user found in request'); + logger.warn('/api/lotw/sync: Unauthorized access attempt'); set.status = 401; return { success: false, error: 'Unauthorized' }; } - console.error('[/api/lotw/sync] User authenticated:', user.id); - try { - // Get user's LoTW credentials from database 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) { - console.error('[/api/lotw/sync] Missing LoTW credentials'); + logger.debug('/api/lotw/sync: Missing LoTW credentials', { userId: user.id }); set.status = 400; return { 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', { lotwUsername: userData.lotwUsername, lotwPassword: userData.lotwPassword, }); - // If enqueueJob returned existingJob, format the response if (!result.success && result.existingJob) { return { success: true, @@ -271,7 +262,7 @@ const app = new Elysia() return result; } catch (error) { - console.error('Error in /api/lotw/sync:', error); + logger.error('Error in /api/lotw/sync', { error: error.message }); set.status = 500; return { success: false, @@ -488,7 +479,7 @@ const app = new Elysia() // Start server .listen(3001); -console.log(`🦊 Backend server running at http://localhost:${app.server?.port}`); -console.log(`📡 API endpoints available at http://localhost:${app.server?.port}/api`); +logger.info(`Backend server running`, { port: app.server?.port, url: `http://localhost:${app.server?.port}` }); +logger.info(`API endpoints available`, { url: `http://localhost:${app.server?.port}/api` }); export default app; diff --git a/src/backend/init-db.js b/src/backend/init-db.js index a17abfa..b2f617a 100644 --- a/src/backend/init-db.js +++ b/src/backend/init-db.js @@ -1,26 +1,15 @@ -import Database from 'bun:sqlite'; -import { drizzle } from 'drizzle-orm/bun-sqlite'; -import * as schema from './db/schema/index.js'; +import logger from './config/logger.js'; +import { execSync } from 'child_process'; -const sqlite = new Database('./award.db'); -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'); +logger.info('Initializing database...'); try { execSync('bun drizzle-kit push', { cwd: '/Users/joergdorgeist/Dev/award', stdio: 'inherit' }); - console.log('✓ Database initialized successfully!'); + logger.info('Database initialized successfully'); } catch (error) { - console.error('Failed to initialize database:', error); + logger.error('Failed to initialize database', { error: error.message }); process.exit(1); } diff --git a/src/backend/routes/auth.js b/src/backend/routes/auth.js deleted file mode 100644 index d8ccced..0000000 --- a/src/backend/routes/auth.js +++ /dev/null @@ -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(), - }), - } - ); -}; diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index b959f79..731d35f 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -10,7 +10,7 @@ const SALT_ROUNDS = 10; * @param {string} password - Plain text password * @returns {Promise} Hashed password */ -export async function hashPassword(password) { +async function hashPassword(password) { return bcrypt.hash(password, SALT_ROUNDS); } @@ -20,7 +20,7 @@ export async function hashPassword(password) { * @param {string} hash - Hashed password * @returns {Promise} True if password matches */ -export async function verifyPassword(password, hash) { +async function verifyPassword(password, hash) { return bcrypt.compare(password, hash); } @@ -35,11 +35,11 @@ export async function verifyPassword(password, hash) { */ export async function registerUser({ email, password, callsign }) { // Check if user already exists - const existingUser = await db + const [existingUser] = await db .select() .from(users) .where(eq(users.email, email)) - .get(); + .limit(1); if (existingUser) { throw new Error('Email already registered'); @@ -72,11 +72,11 @@ export async function registerUser({ email, password, callsign }) { */ export async function authenticateUser(email, password) { // Find user by email - const user = await db + const [user] = await db .select() .from(users) .where(eq(users.email, email)) - .get(); + .limit(1); if (!user) { throw new Error('Invalid email or password'); @@ -99,11 +99,11 @@ export async function authenticateUser(email, password) { * @returns {Promise} User object (without password) or null */ export async function getUserById(userId) { - const user = await db + const [user] = await db .select() .from(users) .where(eq(users.id, userId)) - .get(); + .limit(1); if (!user) return null; diff --git a/src/backend/services/job-queue.service.js b/src/backend/services/job-queue.service.js index 5940dd8..e286404 100644 --- a/src/backend/services/job-queue.service.js +++ b/src/backend/services/job-queue.service.js @@ -1,6 +1,7 @@ import { db } from '../config/database.js'; import { syncJobs } from '../db/schema/index.js'; import { eq, and, desc, or, lt } from 'drizzle-orm'; +import logger from '../config/logger.js'; /** * Background Job Queue Service @@ -43,12 +44,12 @@ export function registerProcessor(type, processor) { * @returns {Promise} Job object with ID */ 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 const existingJob = await getUserActiveJob(userId, type); if (existingJob) { - console.error('[enqueueJob] Found existing active job:', existingJob.id); + logger.debug('Existing active job found', { jobId: existingJob.id }); return { success: false, 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 - console.error('[enqueueJob] Creating job record in database...'); const [job] = await db .insert(syncJobs) .values({ @@ -68,11 +68,11 @@ export async function enqueueJob(userId, type, data = {}) { }) .returning(); - console.error('[enqueueJob] Job created:', job.id); + logger.info('Job created', { jobId: job.id, type, userId }); // Start processing asynchronously (don't await) 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 { @@ -145,7 +145,7 @@ async function processJobAsync(jobId, userId, type, data) { * @param {number} jobId - Job ID * @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)); } @@ -154,7 +154,7 @@ export async function updateJob(jobId, updates) { * @param {number} jobId - Job ID * @returns {Promise} 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); return job || null; } @@ -174,7 +174,7 @@ export async function getJobStatus(jobId) { try { parsedResult = JSON.parse(job.result); } 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} Active job or 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() const conditions = [ eq(syncJobs.userId, userId), @@ -213,20 +211,14 @@ export async function getUserActiveJob(userId, type = null) { conditions.push(eq(syncJobs.type, type)); } - try { - const [job] = await db - .select() - .from(syncJobs) - .where(and(...conditions)) - .orderBy(desc(syncJobs.createdAt)) - .limit(1); + const [job] = await db + .select() + .from(syncJobs) + .where(and(...conditions)) + .orderBy(desc(syncJobs.createdAt)) + .limit(1); - console.error('[getUserActiveJob] Result:', job ? `Found job ${job.id}` : 'No active job'); - return job || null; - } catch (error) { - console.error('[getUserActiveJob] Database error:', error); - throw error; - } + return job || null; } /** @@ -284,6 +276,7 @@ export async function cleanupOldJobs(daysOld = 7) { ) ); + logger.info('Cleaned up old jobs', { count: result, daysOld }); return result; } diff --git a/src/backend/services/lotw.service.js b/src/backend/services/lotw.service.js index 7696e94..94a3a7e 100644 --- a/src/backend/services/lotw.service.js +++ b/src/backend/services/lotw.service.js @@ -1,7 +1,8 @@ import { db } from '../config/database.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 logger from '../config/logger.js'; /** * LoTW (Logbook of the World) Service @@ -9,41 +10,25 @@ import { registerProcessor, updateJobProgress } from './job-queue.service.js'; */ // Wavelog-compatible constants -const LOTW_CONNECT_TIMEOUT = 30; // CURLOPT_CONNECTTIMEOUT from Wavelog +const LOTW_CONNECT_TIMEOUT = 30; // Configuration for long-polling const POLLING_CONFIG = { - maxRetries: 30, // Maximum number of retry attempts - retryDelay: 10000, // Delay between retries in ms (10 seconds) - requestTimeout: 60000, // Timeout for individual requests in ms (1 minute) - maxTotalTime: 600000, // Maximum total time to wait in ms (10 minutes) + maxRetries: 30, + retryDelay: 10000, + requestTimeout: 60000, + maxTotalTime: 600000, }; /** * 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) { const trimmed = responseData.trim().toLowerCase(); - // LoTW returns various messages when report is not ready: - // - Empty responses - // - "Report is being prepared" or similar messages - // - HTML error pages - // - Very short responses that aren't valid ADIF + if (trimmed.length < 100) return true; + if (trimmed.includes('') || trimmed.includes('')) return true; - // Check for empty or very short responses - if (trimmed.length < 100) { - return true; - } - - // Check for HTML responses (error pages) - if (trimmed.includes('') || trimmed.includes('')) { - return true; - } - - // Check for common "not ready" messages const pendingMessages = [ 'report is being prepared', 'your report is being generated', @@ -54,12 +39,9 @@ function isReportPending(responseData) { ]; for (const msg of pendingMessages) { - if (trimmed.includes(msg)) { - return true; - } + if (trimmed.includes(msg)) return true; } - // Check if it looks like valid ADIF data (should start with } - */ -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); /** * 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 of QSO objects */ -export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { - // LoTW report URL +async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { 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({ login: lotwUsername, password: lotwPassword, - qso_query: '1', // REQUIRED: Without this, no QSO records are returned - qso_qsl: 'yes', // Only get QSOs with QSLs (confirmed) - qso_qsldetail: 'yes', // Include QSL details (station location) - qso_mydetail: 'yes', // Include my station details - qso_withown: 'yes', // Include own callsign + qso_query: '1', + qso_qsl: 'yes', + qso_qsldetail: 'yes', + qso_mydetail: 'yes', + 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) { const dateStr = sinceDate.toISOString().split('T')[0]; params.append('qso_qslsince', dateStr); - console.error('Date filter:', dateStr, '(Incremental sync since last QSL date)'); + logger.debug('Incremental sync since', { date: dateStr }); } else { - console.error('No date filter - fetching ALL QSOs (first sync)'); + logger.debug('Full sync - fetching all QSOs'); } const fullUrl = `${url}?${params.toString()}`; - - console.error('Fetching from LoTW:', fullUrl.replace(/password=[^&]+/, 'password=***')); + logger.debug('Fetching from LoTW', { url: fullUrl.replace(/password=[^&]+/, 'password=***') }); const startTime = Date.now(); - // Long-polling loop for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) { + const elapsed = Date.now() - startTime; + if (elapsed > POLLING_CONFIG.maxTotalTime) { + throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`); + } + + if (attempt > 0) { + logger.debug(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries}`, { elapsed: Math.round(elapsed / 1000) }); + } + try { - // Check if we've exceeded max total time - const elapsed = Date.now() - startTime; - if (elapsed > POLLING_CONFIG.maxTotalTime) { - throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`); - } - - if (attempt > 0) { - console.error(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries} (elapsed: ${Math.round(elapsed / 1000)}s)`); - } - - // Make request with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), POLLING_CONFIG.requestTimeout); const response = await fetch(fullUrl, { signal: controller.signal }); - clearTimeout(timeoutId); - // Handle HTTP errors if (!response.ok) { if (response.status === 503) { - // Service unavailable - might be temporary, retry - console.error('LoTW returned 503 (Service Unavailable), waiting before retry...'); + logger.warn('LoTW returned 503, retrying...'); await sleep(POLLING_CONFIG.retryDelay); continue; } else if (response.status === 401) { @@ -148,62 +108,48 @@ export async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = } else if (response.status === 404) { throw new Error('LoTW service not found (404). The LoTW API URL may have changed.'); } else { - // Other errors - log but retry - console.error(`LoTW returned ${response.status} ${response.statusText}, waiting before retry...`); + logger.warn(`LoTW returned ${response.status}, retrying...`); await sleep(POLLING_CONFIG.retryDelay); continue; } } - // Get 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')) { throw new Error('Username/password incorrect'); } - // Wavelog: Check if file starts with expected header const header = adifData.trim().substring(0, 39).toLowerCase(); if (!header.includes('arrl logbook of the world')) { - // This might be because the report is still pending 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); continue; } throw new Error('Downloaded LoTW report is invalid. Check your credentials.'); } - // We have valid data! - console.error('LoTW report ready, parsing ADIF data...'); - console.error('ADIF preview:', adifData.substring(0, 200)); + logger.info('LoTW report downloaded successfully', { size: adifData.length }); - // Parse ADIF format const qsos = parseADIF(adifData); - console.error(`Successfully parsed ${qsos.length} QSOs from LoTW`); + logger.info('Parsed QSOs from LoTW', { count: qsos.length }); return qsos; } catch (error) { - const elapsed = Date.now() - startTime; - if (error.name === 'AbortError') { - console.error(`Request timeout on attempt ${attempt + 1}, retrying...`); + logger.debug('Request timeout, retrying...'); await sleep(POLLING_CONFIG.retryDelay); continue; } - // Re-throw credential/auth errors immediately if (error.message.includes('credentials') || error.message.includes('401') || error.message.includes('404')) { throw error; } - // For other errors, log and retry if we haven't exhausted retries if (attempt < POLLING_CONFIG.maxRetries - 1) { - console.error(`Error on attempt ${attempt + 1}: ${error.message}`); - console.error('Retrying...'); + logger.warn(`Error on attempt ${attempt + 1}`, { error: error.message }); await sleep(POLLING_CONFIG.retryDelay); continue; } 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); 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 - * @param {string} adifData - Raw ADIF text data - * @returns {Array} Array of QSOs (each QSO is an array of field objects) */ function parseADIF(adifData) { const qsos = []; const records = adifData.split(''); - console.error(`Total records after splitting by : ${records.length}`); - - for (let i = 0; i < records.length; i++) { - const record = records[i]; - - // Skip empty records or records that are just header info + for (const record of records) { if (!record.trim()) continue; if (record.trim().startsWith('<') && !record.includes('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; let match; while ((match = regex.exec(record)) !== null) { const [fullMatch, fieldName, lengthStr, firstChar] = match; const length = parseInt(lengthStr, 10); - - // Extract exactly 'length' characters starting from the position after the '>' - const valueStart = match.index + fullMatch.length - 1; // -1 because firstChar is already captured + const valueStart = match.index + fullMatch.length - 1; const value = record.substring(valueStart, valueStart + length); 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; } - // Only add if we have actual QSO data (has CALL or call field) if (Object.keys(qso).length > 0 && (qso.call || qso.call)) { 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; } /** * 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 { userId, 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) { if (!band) return null; const bandMap = { - '160m': '160m', - '80m': '80m', - '60m': '60m', - '40m': '40m', - '30m': '30m', - '20m': '20m', - '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', + '160m': '160m', '80m': '80m', '60m': '60m', '40m': '40m', + '30m': '30m', '20m': '20m', '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; } -/** - * Normalize mode name - * @param {string} mode - Mode from ADIF - * @returns {string} Normalized mode - */ function normalizeMode(mode) { if (!mode) return ''; const modeMap = { - 'cw': 'CW', - 'ssb': 'SSB', - 'am': 'AM', - 'fm': 'FM', - 'rtty': 'RTTY', - 'psk31': 'PSK31', - 'psk63': 'PSK63', - 'ft8': 'FT8', - 'ft4': 'FT4', - 'jt65': 'JT65', - 'jt9': 'JT9', - 'js8': 'JS8', - 'mfsk': 'MFSK', - ' Olivia': 'OLIVIA', + 'cw': 'CW', 'ssb': 'SSB', 'am': 'AM', 'fm': 'FM', + '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()]; @@ -371,139 +254,80 @@ function normalizeMode(mode) { /** * 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} Sync result with counts */ -export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) { - try { - // Fetch QSOs from LoTW - const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate); +async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) { + const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate); - if (!adifQSOs || adifQSOs.length === 0) { - return { - success: true, - total: 0, - added: 0, - updated: 0, - message: 'No QSOs found in LoTW', - }; - } - - let addedCount = 0; - let updatedCount = 0; - const errors = []; - - // Process each QSO - for (const qsoData of adifQSOs) { - try { - console.error('Raw ADIF QSO data:', JSON.stringify(qsoData)); - 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 - .select() - .from(qsos) - .where(eq(qsos.userId, userId)) - .where(eq(qsos.callsign, dbQSO.callsign)) - .where(eq(qsos.qsoDate, dbQSO.qsoDate)) - .where(eq(qsos.band, dbQSO.band)) - .where(eq(qsos.mode, dbQSO.mode)) - .limit(1); - - console.error('Existing QSOs found:', existing.length); - 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 - .update(qsos) - .set({ - lotwQslRdate: dbQSO.lotwQslRdate, - lotwQslRstatus: dbQSO.lotwQslRstatus, - lotwSyncedAt: dbQSO.lotwSyncedAt, - }) - .where(eq(qsos.id, existing[0].id)); - updatedCount++; - } else { - // Insert new QSO - 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++; - } catch (insertError) { - console.error('Insert failed:', insertError.message); - console.error('Insert error details:', insertError); - throw insertError; - } - } - } catch (error) { - console.error('ERROR processing QSO:', error); - errors.push({ - qso: qsoData, - error: error.message, - }); - } - } - - return { - success: true, - total: adifQSOs.length, - added: addedCount, - updated: updatedCount, - errors: errors.length > 0 ? errors : undefined, - }; - } catch (error) { - throw new Error(`LoTW sync failed: ${error.message}`); + if (!adifQSOs || adifQSOs.length === 0) { + return { success: true, total: 0, added: 0, updated: 0, message: 'No QSOs found in LoTW' }; } + + let addedCount = 0; + let updatedCount = 0; + const errors = []; + + for (const qsoData of adifQSOs) { + try { + const dbQSO = convertQSODatabaseFormat(qsoData, userId); + + const existing = await db + .select() + .from(qsos) + .where(eq(qsos.userId, userId)) + .where(eq(qsos.callsign, dbQSO.callsign)) + .where(eq(qsos.qsoDate, dbQSO.qsoDate)) + .where(eq(qsos.band, dbQSO.band)) + .where(eq(qsos.mode, dbQSO.mode)) + .limit(1); + + if (existing.length > 0) { + await db + .update(qsos) + .set({ + lotwQslRdate: dbQSO.lotwQslRdate, + lotwQslRstatus: dbQSO.lotwQslRstatus, + lotwSyncedAt: dbQSO.lotwSyncedAt, + }) + .where(eq(qsos.id, existing[0].id)); + updatedCount++; + } else { + await db.insert(qsos).values(dbQSO); + addedCount++; + } + } catch (error) { + logger.error('Error processing QSO', { error: error.message, qso: qsoData }); + errors.push({ qso: qsoData, error: error.message }); + } + } + + logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount }); + + return { + success: true, + total: adifQSOs.length, + added: addedCount, + updated: updatedCount, + errors: errors.length > 0 ? errors : undefined, + }; } /** * 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} Paginated QSOs */ export async function getUserQSOs(userId, filters = {}, options = {}) { - const { eq, and, desc, sql } = await import('drizzle-orm'); - 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)]; - if (filters.band) { - conditions.push(eq(qsos.band, filters.band)); - } + if (filters.band) conditions.push(eq(qsos.band, filters.band)); + if (filters.mode) conditions.push(eq(qsos.mode, filters.mode)); + if (filters.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 totalCount = allResults.length; - // Calculate offset const offset = (page - 1) * limit; - // Get paginated results const results = await db .select() .from(qsos) @@ -512,8 +336,6 @@ export async function getUserQSOs(userId, filters = {}, options = {}) { .limit(limit) .offset(offset); - console.error('getUserQSOs returning', results.length, 'QSOs (page', page, 'of', Math.ceil(totalCount / limit), ')'); - return { qsos: results, pagination: { @@ -529,18 +351,11 @@ export async function getUserQSOs(userId, filters = {}, options = {}) { /** * Get QSO statistics for a user - * @param {number} userId - User ID - * @returns {Promise} Statistics object */ export async function getQSOStats(userId) { - const { eq } = await import('drizzle-orm'); 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'); - // Count unique entities const uniqueEntities = new Set(); const uniqueBands = new Set(); const uniqueModes = new Set(); @@ -551,43 +366,28 @@ export async function getQSOStats(userId) { if (q.mode) uniqueModes.add(q.mode); }); - const stats = { + return { total: allQSOs.length, confirmed: confirmed.length, uniqueEntities: uniqueEntities.size, uniqueBands: uniqueBands.size, uniqueModes: uniqueModes.size, }; - - console.error('getQSOStats returning:', stats); - - return stats; } /** * 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} Last QSL date or null */ -export async function getLastLoTWQSLDate(userId) { - const { eq } = await import('drizzle-orm'); - - // Get the most recent lotwQslRdate for this user +async function getLastLoTWQSLDate(userId) { const [result] = await db .select({ maxDate: max(qsos.lotwQslRdate) }) .from(qsos) .where(eq(qsos.userId, userId)); - if (!result || !result.maxDate) { - return null; - } + if (!result || !result.maxDate) return null; - // Parse ADIF date format (YYYYMMDD) to Date const dateStr = result.maxDate; - if (!dateStr || dateStr === '') { - return null; - } + if (!dateStr || dateStr === '') return null; const year = dateStr.substring(0, 4); const month = dateStr.substring(4, 6); @@ -596,172 +396,106 @@ export async function getLastLoTWQSLDate(userId) { 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 - * @param {number} jobId - Job ID - * @param {number} userId - User ID - * @param {Object} data - Job data { lotwUsername, lotwPassword } - * @returns {Promise} Sync result */ export async function syncQSOsForJob(jobId, userId, data) { const { lotwUsername, lotwPassword } = data; - try { - // Update job progress: starting - await updateJobProgress(jobId, { - message: 'Fetching QSOs from LoTW...', - step: 'fetch', - }); + await updateJobProgress(jobId, { + message: 'Fetching QSOs from LoTW...', + step: 'fetch', + }); - // Get last LoTW QSL date for incremental sync - const lastQSLDate = await getLastLoTWQSLDate(userId); + const lastQSLDate = await getLastLoTWQSLDate(userId); + const sinceDate = lastQSLDate || new Date('2000-01-01'); - // 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'); + if (lastQSLDate) { + logger.info(`Job ${jobId}: Incremental sync`, { since: sinceDate.toISOString().split('T')[0] }); + } else { + logger.info(`Job ${jobId}: Full sync`); + } - if (lastQSLDate) { - console.error(`[Job ${jobId}] Incremental sync since ${sinceDate.toISOString().split('T')[0]}`); - } else { - console.error(`[Job ${jobId}] Full sync - fetching ALL QSOs since 2000-01-01`); - } + const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate); - // Fetch from LoTW - const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate); + if (!adifQSOs || adifQSOs.length === 0) { + return { success: true, total: 0, added: 0, updated: 0, message: 'No QSOs found in LoTW' }; + } - if (!adifQSOs || adifQSOs.length === 0) { - return { - success: true, - total: 0, - added: 0, - updated: 0, - message: 'No QSOs found in LoTW', - }; - } + await updateJobProgress(jobId, { + message: `Processing ${adifQSOs.length} QSOs...`, + step: 'process', + total: adifQSOs.length, + processed: 0, + }); - // Update job progress: processing - await updateJobProgress(jobId, { - message: `Processing ${adifQSOs.length} QSOs...`, - step: 'process', - total: adifQSOs.length, - processed: 0, - }); + let addedCount = 0; + let updatedCount = 0; + const errors = []; - let addedCount = 0; - let updatedCount = 0; - const errors = []; + for (let i = 0; i < adifQSOs.length; i++) { + const qsoData = adifQSOs[i]; - // Process each QSO - for (let i = 0; i < adifQSOs.length; i++) { - const qsoData = adifQSOs[i]; + try { + const dbQSO = convertQSODatabaseFormat(qsoData, userId); - try { - const dbQSO = convertQSODatabaseFormat(qsoData, userId); - - // Check if QSO already exists - const { eq, and } = await import('drizzle-orm'); - const existing = await db - .select() - .from(qsos) - .where( - and( - eq(qsos.userId, userId), - eq(qsos.callsign, dbQSO.callsign), - eq(qsos.qsoDate, dbQSO.qsoDate), - eq(qsos.band, dbQSO.band), - eq(qsos.mode, dbQSO.mode) - ) + const existing = await db + .select() + .from(qsos) + .where( + and( + eq(qsos.userId, userId), + eq(qsos.callsign, dbQSO.callsign), + eq(qsos.qsoDate, dbQSO.qsoDate), + eq(qsos.band, dbQSO.band), + eq(qsos.mode, dbQSO.mode) ) - .limit(1); + ) + .limit(1); - if (existing.length > 0) { - // Update existing QSO - await db - .update(qsos) - .set({ - lotwQslRdate: dbQSO.lotwQslRdate, - lotwQslRstatus: dbQSO.lotwQslRstatus, - lotwSyncedAt: dbQSO.lotwSyncedAt, - }) - .where(eq(qsos.id, existing[0].id)); - updatedCount++; - } else { - // Insert new QSO - await db.insert(qsos).values(dbQSO); - addedCount++; - } + if (existing.length > 0) { + await db + .update(qsos) + .set({ + lotwQslRdate: dbQSO.lotwQslRdate, + lotwQslRstatus: dbQSO.lotwQslRstatus, + lotwSyncedAt: dbQSO.lotwSyncedAt, + }) + .where(eq(qsos.id, existing[0].id)); + updatedCount++; + } else { + await db.insert(qsos).values(dbQSO); + addedCount++; + } - // Update progress every 10 QSOs - if ((i + 1) % 10 === 0) { - await updateJobProgress(jobId, { - processed: i + 1, - message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`, - }); - } - } catch (error) { - console.error(`[Job ${jobId}] ERROR processing QSO:`, error); - errors.push({ - qso: qsoData, - error: error.message, + if ((i + 1) % 10 === 0) { + await updateJobProgress(jobId, { + processed: i + 1, + message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`, }); } + } catch (error) { + logger.error(`Job ${jobId}: Error processing QSO`, { error: error.message }); + errors.push({ qso: qsoData, error: error.message }); } - - return { - success: true, - total: adifQSOs.length, - added: addedCount, - updated: updatedCount, - 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; } + + logger.info(`Job ${jobId} completed`, { total: adifQSOs.length, added: addedCount, updated: updatedCount }); + + return { + success: true, + total: adifQSOs.length, + added: addedCount, + updated: updatedCount, + errors: errors.length > 0 ? errors : undefined, + }; } /** * Delete all QSOs for a user - * @param {number} userId - User ID - * @returns {Promise} Number of QSOs deleted */ export async function deleteQSOs(userId) { - const { eq } = await import('drizzle-orm'); - const result = await db.delete(qsos).where(eq(qsos.userId, userId)); - return result; } diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index ed53d22..fbe91e9 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -1,5 +1,6 @@ @@ -9,6 +10,21 @@ {#if browser}
+ {#if $auth.user} + + {/if}
@@ -42,6 +58,60 @@ 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 { flex: 1; padding: 2rem 1rem;