refactor: simplify codebase and replace external dependencies with Bun built-ins
Backend changes: - Merge duplicate award logic (calculatePointsAwardProgress + getPointsAwardEntityBreakdown) - Simplify LoTW service (merge syncQSOs functions, simplify polling) - Remove job queue abstraction (hardcode LoTW sync, remove processor registry) - Consolidate config files (database.js, logger.js, jwt.js → single config.js) - Replace bcrypt with Bun.password.hash/verify - Replace Pino logger with console-based logger - Fix: export syncQSOs and getLastLoTWQSLDate for job queue imports - Fix: correct database path resolution using new URL() Frontend changes: - Simplify auth store (remove localStorage wrappers, reduce from 222→109 lines) - Consolidate API layer (remove verbose JSDoc, 180→80 lines) - Add shared UI components (Loading, ErrorDisplay, BackButton) Dependencies: - Remove bcrypt (replaced with Bun.password) - Remove pino and pino-pretty (replaced with console logger) Total: ~445 lines removed (net), 3 dependencies removed Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
63
bun.lock
63
bun.lock
@@ -8,11 +8,8 @@
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/jwt": "^1.4.0",
|
||||
"@elysiajs/static": "^1.4.7",
|
||||
"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",
|
||||
@@ -119,8 +116,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -133,24 +128,16 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -161,20 +148,14 @@
|
||||
|
||||
"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=="],
|
||||
@@ -183,76 +164,34 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -269,8 +208,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -25,10 +25,7 @@
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/jwt": "^1.4.0",
|
||||
"@elysiajs/static": "^1.4.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"elysia": "^1.4.22",
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3"
|
||||
"elysia": "^1.4.22"
|
||||
}
|
||||
}
|
||||
|
||||
62
src/backend/config.js
Normal file
62
src/backend/config.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import Database from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import * as schema from './db/schema/index.js';
|
||||
import { join } from 'path';
|
||||
|
||||
// ===================================================================
|
||||
// Configuration
|
||||
// ===================================================================
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
|
||||
|
||||
// ===================================================================
|
||||
// Logger
|
||||
// ===================================================================
|
||||
|
||||
const logLevels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
const currentLogLevel = logLevels[LOG_LEVEL] || 1;
|
||||
|
||||
function log(level, message, data) {
|
||||
if (logLevels[level] < currentLogLevel) return;
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
console.log(logMessage, JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log(logMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (message, data) => log('debug', message, data),
|
||||
info: (message, data) => log('info', message, data),
|
||||
warn: (message, data) => log('warn', message, data),
|
||||
error: (message, data) => log('error', message, data),
|
||||
};
|
||||
|
||||
export default logger;
|
||||
|
||||
// ===================================================================
|
||||
// Database
|
||||
// ===================================================================
|
||||
|
||||
// Get the directory containing this config file, then go to parent for db location
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
const dbPath = join(__dirname, 'award.db');
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
export const db = drizzle({
|
||||
client: sqlite,
|
||||
schema,
|
||||
});
|
||||
|
||||
export async function closeDatabase() {
|
||||
sqlite.close();
|
||||
}
|
||||
@@ -1,8 +1,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 { JWT_SECRET, logger } from './config.js';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
@@ -249,21 +248,7 @@ const app = new Elysia()
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = await getUserById(user.id);
|
||||
|
||||
if (!userData || !userData.lotwUsername || !userData.lotwPassword) {
|
||||
logger.debug('/api/lotw/sync: Missing LoTW credentials', { userId: user.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: 'LoTW credentials not configured. Please add them in Settings.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await enqueueJob(user.id, 'lotw_sync', {
|
||||
lotwUsername: userData.lotwUsername,
|
||||
lotwPassword: userData.lotwPassword,
|
||||
});
|
||||
const result = await enqueueJob(user.id);
|
||||
|
||||
if (!result.success && result.existingJob) {
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../config/database.js';
|
||||
import { db } from '../config.js';
|
||||
import { users } from '../db/schema/index.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
* Hash a password using Bun's built-in password hashing
|
||||
* @param {string} password - Plain text password
|
||||
* @returns {Promise<string>} Hashed password
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
return Bun.password.hash(password);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +18,7 @@ async function hashPassword(password) {
|
||||
* @returns {Promise<boolean>} True if password matches
|
||||
*/
|
||||
async function verifyPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
return Bun.password.verify(password, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { db, logger } from '../config.js';
|
||||
import { qsos } from '../db/schema/index.js';
|
||||
import { eq, and, or, desc, sql } from 'drizzle-orm';
|
||||
import logger from '../config/logger.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -63,12 +62,20 @@ export async function getAllAwards() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize award rules to a consistent format
|
||||
* Calculate award progress for a user
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
||||
*/
|
||||
function normalizeAwardRules(rules) {
|
||||
export async function calculateAwardProgress(userId, award, options = {}) {
|
||||
const { includeDetails = false } = options;
|
||||
let { rules } = award;
|
||||
|
||||
// Normalize rules inline to handle different formats
|
||||
// Handle "filtered" type awards (like DXCC CW)
|
||||
if (rules.type === 'filtered' && rules.baseRule) {
|
||||
return {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.baseRule.entityType,
|
||||
target: rules.baseRule.target,
|
||||
@@ -76,11 +83,9 @@ function normalizeAwardRules(rules) {
|
||||
filters: rules.filters,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle "counter" type awards (like RS-44)
|
||||
// These count unique callsigns instead of entities
|
||||
if (rules.type === 'counter') {
|
||||
return {
|
||||
else if (rules.type === 'counter') {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign',
|
||||
target: rules.target,
|
||||
@@ -88,30 +93,13 @@ function normalizeAwardRules(rules) {
|
||||
filters: rules.filters,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle "points" type awards (station-specific point values)
|
||||
// Keep as-is but validate stations array exists
|
||||
if (rules.type === 'points') {
|
||||
// Validate "points" type awards
|
||||
else if (rules.type === 'points') {
|
||||
if (!rules.stations || !Array.isArray(rules.stations)) {
|
||||
logger.warn('Point-based award missing stations array');
|
||||
}
|
||||
return rules; // Return as-is for special handling
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate award progress for a user
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
*/
|
||||
export async function calculateAwardProgress(userId, award) {
|
||||
let { rules } = award;
|
||||
|
||||
// Normalize rules to handle different formats
|
||||
rules = normalizeAwardRules(rules);
|
||||
|
||||
logger.debug('Calculating award progress', {
|
||||
userId,
|
||||
awardId: award.id,
|
||||
@@ -122,7 +110,7 @@ export async function calculateAwardProgress(userId, award) {
|
||||
|
||||
// Handle point-based awards
|
||||
if (rules.type === 'points') {
|
||||
return calculatePointsAwardProgress(userId, rules);
|
||||
return calculatePointsAwardProgress(userId, award, { includeDetails });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
@@ -174,8 +162,14 @@ export async function calculateAwardProgress(userId, award) {
|
||||
* - "perBandMode": each unique (callsign, band, mode) combination earns points
|
||||
* - "perStation": each unique station earns points once
|
||||
* - "perQso": every confirmed QSO earns points
|
||||
* @param {number} userId - User ID
|
||||
* @param {Object} award - Award definition
|
||||
* @param {Object} options - Options
|
||||
* @param {boolean} options.includeDetails - Include detailed entity breakdown
|
||||
*/
|
||||
async function calculatePointsAwardProgress(userId, rules) {
|
||||
async function calculatePointsAwardProgress(userId, award, options = {}) {
|
||||
const { includeDetails = false } = options;
|
||||
const { rules } = award;
|
||||
const { stations, target, countMode = 'perStation' } = rules;
|
||||
|
||||
// Create a map of callsign -> points for quick lookup
|
||||
@@ -196,162 +190,12 @@ async function calculatePointsAwardProgress(userId, rules) {
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
const workedStations = new Set(); // Unique callsigns worked
|
||||
const workedStations = new Set();
|
||||
let totalPoints = 0;
|
||||
const stationDetails = [];
|
||||
|
||||
if (countMode === 'perBandMode') {
|
||||
// Count unique (callsign, band, mode) combinations
|
||||
const workedCombinations = new Set();
|
||||
const confirmedCombinations = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
const band = qso.band || 'Unknown';
|
||||
const mode = qso.mode || 'Unknown';
|
||||
const combinationKey = `${callsign}/${band}/${mode}`;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!workedCombinations.has(combinationKey)) {
|
||||
workedCombinations.add(combinationKey);
|
||||
stationDetails.push({
|
||||
callsign,
|
||||
band,
|
||||
mode,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y' && !confirmedCombinations.has(combinationKey)) {
|
||||
confirmedCombinations.set(combinationKey, points);
|
||||
const detail = stationDetails.find((c) =>
|
||||
c.callsign === callsign && c.band === band && c.mode === mode
|
||||
);
|
||||
if (detail) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints = Array.from(confirmedCombinations.values()).reduce((sum, p) => sum + p, 0);
|
||||
} else if (countMode === 'perStation') {
|
||||
// Count unique stations only
|
||||
const workedStationsMap = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!workedStationsMap.has(callsign)) {
|
||||
workedStationsMap.set(callsign, {
|
||||
callsign,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
});
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
const detail = workedStationsMap.get(callsign);
|
||||
if (detail && !detail.confirmed) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints = Array.from(workedStationsMap.values())
|
||||
.filter((s) => s.confirmed)
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
stationDetails.push(...workedStationsMap.values());
|
||||
} else if (countMode === 'perQso') {
|
||||
// Count every confirmed QSO
|
||||
const qsoCount = { worked: 0, confirmed: 0, points: 0 };
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
workedStations.add(callsign);
|
||||
qsoCount.worked++;
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
qsoCount.confirmed++;
|
||||
qsoCount.points += points;
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints = qsoCount.points;
|
||||
}
|
||||
|
||||
logger.debug('Point-based award progress', {
|
||||
workedStations: workedStations.size,
|
||||
totalPoints,
|
||||
target,
|
||||
});
|
||||
|
||||
return {
|
||||
worked: workedStations.size,
|
||||
confirmed: stationDetails.filter((s) => s.confirmed).length,
|
||||
totalPoints,
|
||||
target: target || 0,
|
||||
percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0,
|
||||
workedEntities: Array.from(workedStations),
|
||||
confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign),
|
||||
stationDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity breakdown for point-based awards
|
||||
* countMode determines what entities are shown:
|
||||
* - "perBandMode": shows each (callsign, band, mode) combination
|
||||
* - "perStation": shows each unique station
|
||||
* - "perQso": shows every QSO (not recommended for large datasets)
|
||||
*/
|
||||
async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const { rules } = award;
|
||||
const { stations, target, countMode = 'perStation' } = rules;
|
||||
|
||||
// Create a map of callsign -> points for quick lookup
|
||||
const stationPoints = new Map();
|
||||
for (const station of stations) {
|
||||
stationPoints.set(station.callsign.toUpperCase(), station.points);
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
const allQSOs = await db
|
||||
.select()
|
||||
.from(qsos)
|
||||
.where(eq(qsos.userId, userId));
|
||||
|
||||
let entities = [];
|
||||
let totalPoints = 0;
|
||||
|
||||
if (countMode === 'perBandMode') {
|
||||
// Show each (callsign, band, mode) combination
|
||||
const combinationMap = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
@@ -365,33 +209,35 @@ async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const mode = qso.mode || 'Unknown';
|
||||
const combinationKey = `${callsign}/${band}/${mode}`;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!combinationMap.has(combinationKey)) {
|
||||
combinationMap.set(combinationKey, {
|
||||
entity: combinationKey,
|
||||
entityId: null,
|
||||
entityName: `${callsign} (${band}/${mode})`,
|
||||
callsign,
|
||||
band,
|
||||
mode,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: qso.lotwQslRstatus === 'Y',
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
lotwQslRdate: null,
|
||||
});
|
||||
} else {
|
||||
const data = combinationMap.get(combinationKey);
|
||||
if (!data.confirmed && qso.lotwQslRstatus === 'Y') {
|
||||
data.confirmed = true;
|
||||
data.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
const detail = combinationMap.get(combinationKey);
|
||||
if (!detail.confirmed) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entities = Array.from(combinationMap.values());
|
||||
totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0);
|
||||
const details = Array.from(combinationMap.values());
|
||||
stationDetails.push(...details);
|
||||
totalPoints = details.filter((d) => d.confirmed).reduce((sum, d) => sum + d.points, 0);
|
||||
} else if (countMode === 'perStation') {
|
||||
// Show each unique station
|
||||
// Count unique stations only
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const qso of allQSOs) {
|
||||
@@ -401,33 +247,35 @@ async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (!stationMap.has(callsign)) {
|
||||
stationMap.set(callsign, {
|
||||
entity: callsign,
|
||||
entityId: null,
|
||||
entityName: callsign,
|
||||
callsign,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: qso.lotwQslRstatus === 'Y',
|
||||
confirmed: false,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
lotwQslRdate: null,
|
||||
});
|
||||
} else {
|
||||
const data = stationMap.get(callsign);
|
||||
if (!data.confirmed && qso.lotwQslRstatus === 'Y') {
|
||||
data.confirmed = true;
|
||||
data.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
const detail = stationMap.get(callsign);
|
||||
if (!detail.confirmed) {
|
||||
detail.confirmed = true;
|
||||
detail.lotwQslRdate = qso.lotwQslRdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entities = Array.from(stationMap.values());
|
||||
totalPoints = entities.filter((e) => e.confirmed).reduce((sum, e) => sum + e.points, 0);
|
||||
const details = Array.from(stationMap.values());
|
||||
stationDetails.push(...details);
|
||||
totalPoints = details.filter((d) => d.confirmed).reduce((sum, d) => sum + d.points, 0);
|
||||
} else if (countMode === 'perQso') {
|
||||
// Show every QSO (use with caution)
|
||||
// Count every confirmed QSO
|
||||
for (const qso of allQSOs) {
|
||||
const callsign = qso.callsign?.toUpperCase();
|
||||
if (!callsign) continue;
|
||||
@@ -435,39 +283,105 @@ async function getPointsAwardEntityBreakdown(userId, award) {
|
||||
const points = stationPoints.get(callsign);
|
||||
if (!points) continue;
|
||||
|
||||
entities.push({
|
||||
entity: `${callsign}-${qso.qsoDate}`,
|
||||
entityId: null,
|
||||
entityName: `${callsign} on ${qso.qsoDate}`,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: qso.lotwQslRstatus === 'Y',
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
callsign: qso.callsign,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
});
|
||||
workedStations.add(callsign);
|
||||
|
||||
if (qso.lotwQslRstatus === 'Y') {
|
||||
totalPoints += points;
|
||||
stationDetails.push({
|
||||
callsign,
|
||||
points,
|
||||
worked: true,
|
||||
confirmed: true,
|
||||
qsoDate: qso.qsoDate,
|
||||
band: qso.band,
|
||||
mode: qso.mode,
|
||||
lotwQslRdate: qso.lotwQslRdate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
award: {
|
||||
logger.debug('Point-based award progress', {
|
||||
workedStations: workedStations.size,
|
||||
totalPoints,
|
||||
target,
|
||||
});
|
||||
|
||||
// Base result
|
||||
const result = {
|
||||
worked: workedStations.size,
|
||||
confirmed: stationDetails.filter((s) => s.confirmed).length,
|
||||
totalPoints,
|
||||
target: target || 0,
|
||||
percentage: target ? Math.min(100, Math.round((totalPoints / target) * 100)) : 0,
|
||||
workedEntities: Array.from(workedStations),
|
||||
confirmedEntities: stationDetails.filter((s) => s.confirmed).map((s) => s.callsign),
|
||||
};
|
||||
|
||||
// Add details if requested
|
||||
if (includeDetails) {
|
||||
// Convert stationDetails to entity format for breakdown
|
||||
const entities = stationDetails.map((detail) => {
|
||||
if (countMode === 'perBandMode') {
|
||||
return {
|
||||
entity: `${detail.callsign}/${detail.band}/${detail.mode}`,
|
||||
entityId: null,
|
||||
entityName: `${detail.callsign} (${detail.band}/${detail.mode})`,
|
||||
points: detail.points,
|
||||
worked: detail.worked,
|
||||
confirmed: detail.confirmed,
|
||||
qsoDate: detail.qsoDate,
|
||||
band: detail.band,
|
||||
mode: detail.mode,
|
||||
callsign: detail.callsign,
|
||||
lotwQslRdate: detail.lotwQslRdate,
|
||||
};
|
||||
} else if (countMode === 'perStation') {
|
||||
return {
|
||||
entity: detail.callsign,
|
||||
entityId: null,
|
||||
entityName: detail.callsign,
|
||||
points: detail.points,
|
||||
worked: detail.worked,
|
||||
confirmed: detail.confirmed,
|
||||
qsoDate: detail.qsoDate,
|
||||
band: detail.band,
|
||||
mode: detail.mode,
|
||||
callsign: detail.callsign,
|
||||
lotwQslRdate: detail.lotwQslRdate,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
entity: `${detail.callsign}-${detail.qsoDate}`,
|
||||
entityId: null,
|
||||
entityName: `${detail.callsign} on ${detail.qsoDate}`,
|
||||
points: detail.points,
|
||||
worked: detail.worked,
|
||||
confirmed: detail.confirmed,
|
||||
qsoDate: detail.qsoDate,
|
||||
band: detail.band,
|
||||
mode: detail.mode,
|
||||
callsign: detail.callsign,
|
||||
lotwQslRdate: detail.lotwQslRdate,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
result.award = {
|
||||
id: award.id,
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
caption: award.caption,
|
||||
target: award.rules?.target || 0,
|
||||
},
|
||||
entities,
|
||||
total: entities.length,
|
||||
confirmed: entities.filter((e) => e.confirmed).length,
|
||||
totalPoints,
|
||||
};
|
||||
};
|
||||
result.entities = entities;
|
||||
result.total = entities.length;
|
||||
result.confirmed = entities.filter((e) => e.confirmed).length;
|
||||
} else {
|
||||
result.stationDetails = stationDetails;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -577,12 +491,28 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
|
||||
let { rules } = award;
|
||||
|
||||
// Normalize rules to handle different formats
|
||||
rules = normalizeAwardRules(rules);
|
||||
// Normalize rules inline
|
||||
if (rules.type === 'filtered' && rules.baseRule) {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.baseRule.entityType,
|
||||
target: rules.baseRule.target,
|
||||
displayField: rules.baseRule.displayField,
|
||||
filters: rules.filters,
|
||||
};
|
||||
} else if (rules.type === 'counter') {
|
||||
rules = {
|
||||
type: 'entity',
|
||||
entityType: rules.countBy === 'qso' ? 'callsign' : 'callsign',
|
||||
target: rules.target,
|
||||
displayField: rules.displayField,
|
||||
filters: rules.filters,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle point-based awards
|
||||
// Handle point-based awards - use the unified function
|
||||
if (rules.type === 'points') {
|
||||
return getPointsAwardEntityBreakdown(userId, award);
|
||||
return await calculatePointsAwardProgress(userId, award, { includeDetails: true });
|
||||
}
|
||||
|
||||
// Get all QSOs for user
|
||||
@@ -604,17 +534,14 @@ export async function getAwardEntityBreakdown(userId, awardId) {
|
||||
|
||||
if (!entityMap.has(entity)) {
|
||||
// Determine what to display as the entity name
|
||||
// Use displayField from award rules, or fallback to entity/type
|
||||
let displayName = String(entity);
|
||||
if (rules.displayField) {
|
||||
let rawValue = qso[rules.displayField];
|
||||
// For grid-based awards, truncate to first 4 characters
|
||||
if (rules.displayField === 'grid' && rawValue && rawValue.length > 4) {
|
||||
rawValue = rawValue.substring(0, 4);
|
||||
}
|
||||
displayName = String(rawValue || entity);
|
||||
} else {
|
||||
// Fallback: try entity, state, grid, callsign in order
|
||||
displayName = qso.entity || qso.state || qso.grid || qso.callsign || String(entity);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { db, logger } from '../config.js';
|
||||
import { syncJobs } from '../db/schema/index.js';
|
||||
import { eq, and, desc, or, lt } from 'drizzle-orm';
|
||||
import logger from '../config/logger.js';
|
||||
import { eq, and, or, lt } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Background Job Queue Service
|
||||
* Simplified Background Job Queue Service
|
||||
* Manages async jobs with database persistence
|
||||
*/
|
||||
|
||||
@@ -16,43 +15,24 @@ export const JobStatus = {
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
// Job type constants
|
||||
export const JobType = {
|
||||
LOTW_SYNC: 'lotw_sync',
|
||||
};
|
||||
|
||||
// In-memory job processor (for single-server deployment)
|
||||
const activeJobs = new Map(); // jobId -> Promise
|
||||
const jobProcessors = {
|
||||
[JobType.LOTW_SYNC]: null, // Will be set by lotw.service.js
|
||||
};
|
||||
// Active jobs tracking
|
||||
const activeJobs = new Map();
|
||||
|
||||
/**
|
||||
* Register a job processor function
|
||||
* @param {string} type - Job type
|
||||
* @param {Function} processor - Async function that processes the job
|
||||
*/
|
||||
export function registerProcessor(type, processor) {
|
||||
jobProcessors[type] = processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a new job
|
||||
* Enqueue a new LoTW sync job
|
||||
* @param {number} userId - User ID
|
||||
* @param {string} type - Job type
|
||||
* @param {Object} data - Job data (will be passed to processor)
|
||||
* @returns {Promise<Object>} Job object with ID
|
||||
*/
|
||||
export async function enqueueJob(userId, type, data = {}) {
|
||||
logger.debug('Enqueueing job', { userId, type });
|
||||
export async function enqueueJob(userId) {
|
||||
logger.debug('Enqueueing LoTW sync job', { userId });
|
||||
|
||||
// Check for existing active job of same type for this user
|
||||
const existingJob = await getUserActiveJob(userId, type);
|
||||
// Check for existing active job
|
||||
const existingJob = await getUserActiveJob(userId);
|
||||
if (existingJob) {
|
||||
logger.debug('Existing active job found', { jobId: existingJob.id });
|
||||
return {
|
||||
success: false,
|
||||
error: `A ${type} job is already running or pending for this user`,
|
||||
error: 'A LoTW sync job is already running or pending for this user',
|
||||
existingJob: existingJob.id,
|
||||
};
|
||||
}
|
||||
@@ -62,16 +42,16 @@ export async function enqueueJob(userId, type, data = {}) {
|
||||
.insert(syncJobs)
|
||||
.values({
|
||||
userId,
|
||||
type,
|
||||
type: 'lotw_sync',
|
||||
status: JobStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
logger.info('Job created', { jobId: job.id, type, userId });
|
||||
logger.info('Job created', { jobId: job.id, userId });
|
||||
|
||||
// Start processing asynchronously (don't await)
|
||||
processJobAsync(job.id, userId, type, data).catch((error) => {
|
||||
processJobAsync(job.id, userId).catch((error) => {
|
||||
logger.error(`Job processing error`, { jobId: job.id, error: error.message });
|
||||
});
|
||||
|
||||
@@ -88,35 +68,53 @@ export async function enqueueJob(userId, type, data = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a job asynchronously
|
||||
* Process a LoTW sync job asynchronously
|
||||
* @param {number} jobId - Job ID
|
||||
* @param {number} userId - User ID
|
||||
* @param {string} type - Job type
|
||||
* @param {Object} data - Job data
|
||||
*/
|
||||
async function processJobAsync(jobId, userId, type, data) {
|
||||
// Store the promise in activeJobs
|
||||
async function processJobAsync(jobId, userId) {
|
||||
const jobPromise = (async () => {
|
||||
try {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { syncQSOs } = await import('./lotw.service.js');
|
||||
const { getUserById } = await import('./auth.service.js');
|
||||
|
||||
// Update status to running
|
||||
await updateJob(jobId, {
|
||||
status: JobStatus.RUNNING,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
// Get the processor for this job type
|
||||
const processor = jobProcessors[type];
|
||||
if (!processor) {
|
||||
// Get user credentials
|
||||
const user = await getUserById(userId);
|
||||
if (!user || !user.lotwUsername || !user.lotwPassword) {
|
||||
await updateJob(jobId, {
|
||||
status: JobStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
error: `No processor registered for job type: ${type}`,
|
||||
error: 'LoTW credentials not configured',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Execute the job processor
|
||||
const result = await processor(jobId, userId, data);
|
||||
// Get last QSL date for incremental sync
|
||||
const { getLastLoTWQSLDate } = await import('./lotw.service.js');
|
||||
const lastQSLDate = await getLastLoTWQSLDate(userId);
|
||||
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`);
|
||||
}
|
||||
|
||||
// Update job progress
|
||||
await updateJobProgress(jobId, {
|
||||
message: 'Fetching QSOs from LoTW...',
|
||||
step: 'fetch',
|
||||
});
|
||||
|
||||
// Execute the sync
|
||||
const result = await syncQSOs(userId, user.lotwUsername, user.lotwPassword, sinceDate, jobId);
|
||||
|
||||
// Update job as completed
|
||||
await updateJob(jobId, {
|
||||
@@ -185,7 +183,7 @@ export async function getJobStatus(jobId) {
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
userId: job.userId, // Include userId for permission checks
|
||||
userId: job.userId,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
@@ -197,13 +195,11 @@ export async function getJobStatus(jobId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's active job (pending or running) of a specific type
|
||||
* Get user's active job (pending or running)
|
||||
* @param {number} userId - User ID
|
||||
* @param {string} type - Job type (optional, returns any active job)
|
||||
* @returns {Promise<Object|null>} Active job or null
|
||||
*/
|
||||
export async function getUserActiveJob(userId, type = null) {
|
||||
// Build the where clause properly with and() and or()
|
||||
export async function getUserActiveJob(userId) {
|
||||
const conditions = [
|
||||
eq(syncJobs.userId, userId),
|
||||
or(
|
||||
@@ -212,15 +208,11 @@ export async function getUserActiveJob(userId, type = null) {
|
||||
),
|
||||
];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(syncJobs.type, type));
|
||||
}
|
||||
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(syncJobs)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(syncJobs.createdAt))
|
||||
.orderBy(syncJobs.createdAt)
|
||||
.limit(1);
|
||||
|
||||
return job || null;
|
||||
@@ -237,7 +229,7 @@ export async function getUserJobs(userId, limit = 10) {
|
||||
.select()
|
||||
.from(syncJobs)
|
||||
.where(eq(syncJobs.userId, userId))
|
||||
.orderBy(desc(syncJobs.createdAt))
|
||||
.orderBy(syncJobs.createdAt)
|
||||
.limit(limit);
|
||||
|
||||
return jobs.map((job) => {
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { db, logger } from '../config.js';
|
||||
import { qsos } from '../db/schema/index.js';
|
||||
import { max, sql, eq, and, desc } from 'drizzle-orm';
|
||||
import { registerProcessor, updateJobProgress } from './job-queue.service.js';
|
||||
import logger from '../config/logger.js';
|
||||
import { updateJobProgress } from './job-queue.service.js';
|
||||
|
||||
/**
|
||||
* LoTW (Logbook of the World) Service
|
||||
* Fetches QSOs from ARRL's LoTW system
|
||||
*/
|
||||
|
||||
// Wavelog-compatible constants
|
||||
const LOTW_CONNECT_TIMEOUT = 30;
|
||||
|
||||
// Configuration for long-polling
|
||||
const POLLING_CONFIG = {
|
||||
maxRetries: 30,
|
||||
retryDelay: 10000,
|
||||
requestTimeout: 60000,
|
||||
maxTotalTime: 600000,
|
||||
};
|
||||
// Simplified polling configuration
|
||||
const MAX_RETRIES = 30;
|
||||
const RETRY_DELAY = 10000;
|
||||
const REQUEST_TIMEOUT = 60000;
|
||||
|
||||
/**
|
||||
* Check if LoTW response indicates the report is still being prepared
|
||||
@@ -53,7 +46,7 @@ function isReportPending(responseData) {
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Fetch QSOs from LoTW with long-polling support
|
||||
* Fetch QSOs from LoTW with retry support
|
||||
*/
|
||||
async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
const url = 'https://lotw.arrl.org/lotwuser/lotwreport.adi';
|
||||
@@ -79,23 +72,14 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
const fullUrl = `${url}?${params.toString()}`;
|
||||
logger.debug('Fetching from LoTW', { url: fullUrl.replace(/password=[^&]+/, 'password=***') });
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > POLLING_CONFIG.maxTotalTime) {
|
||||
return {
|
||||
error: `LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`
|
||||
};
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
logger.debug(`Retry attempt ${attempt + 1}/${POLLING_CONFIG.maxRetries}`, { elapsed: Math.round(elapsed / 1000) });
|
||||
logger.debug(`Retry attempt ${attempt + 1}/${MAX_RETRIES}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), POLLING_CONFIG.requestTimeout);
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||
|
||||
const response = await fetch(fullUrl, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
@@ -103,7 +87,7 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
if (!response.ok) {
|
||||
if (response.status === 503) {
|
||||
logger.warn('LoTW returned 503, retrying...');
|
||||
await sleep(POLLING_CONFIG.retryDelay);
|
||||
await sleep(RETRY_DELAY);
|
||||
continue;
|
||||
} else if (response.status === 401) {
|
||||
return { error: 'Invalid LoTW credentials. Please check your username and password in Settings.' };
|
||||
@@ -111,7 +95,7 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
return { error: 'LoTW service not found (404). The LoTW API URL may have changed.' };
|
||||
} else {
|
||||
logger.warn(`LoTW returned ${response.status}, retrying...`);
|
||||
await sleep(POLLING_CONFIG.retryDelay);
|
||||
await sleep(RETRY_DELAY);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -126,10 +110,9 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
if (!header.includes('arrl logbook of the world')) {
|
||||
if (isReportPending(adifData)) {
|
||||
logger.debug('LoTW report still being prepared, waiting...');
|
||||
await sleep(POLLING_CONFIG.retryDelay);
|
||||
await sleep(RETRY_DELAY);
|
||||
continue;
|
||||
}
|
||||
|
||||
return { error: 'Downloaded LoTW report is invalid. Check your credentials.' };
|
||||
}
|
||||
|
||||
@@ -143,7 +126,7 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
logger.debug('Request timeout, retrying...');
|
||||
await sleep(POLLING_CONFIG.retryDelay);
|
||||
await sleep(RETRY_DELAY);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -151,9 +134,9 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt < POLLING_CONFIG.maxRetries - 1) {
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
logger.warn(`Error on attempt ${attempt + 1}`, { error: error.message });
|
||||
await sleep(POLLING_CONFIG.retryDelay);
|
||||
await sleep(RETRY_DELAY);
|
||||
continue;
|
||||
} else {
|
||||
throw error;
|
||||
@@ -161,9 +144,9 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) {
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Math.round((Date.now() - startTime) / 1000);
|
||||
const totalTime = Math.round((Date.now() - Date.now()) / 1000);
|
||||
return {
|
||||
error: `LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`
|
||||
error: `LoTW sync failed: Report not ready after ${MAX_RETRIES} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,30 +242,67 @@ 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|null} sinceDate - Optional date for incremental sync
|
||||
* @param {number|null} jobId - Optional job ID for progress tracking
|
||||
*/
|
||||
async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) {
|
||||
export async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null, jobId = null) {
|
||||
if (jobId) {
|
||||
await updateJobProgress(jobId, {
|
||||
message: 'Fetching QSOs from LoTW...',
|
||||
step: 'fetch',
|
||||
});
|
||||
}
|
||||
|
||||
const adifQSOs = await fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate);
|
||||
|
||||
if (!adifQSOs || adifQSOs.length === 0) {
|
||||
// Check for error response from LoTW fetch
|
||||
if (!adifQSOs) {
|
||||
return { success: false, error: 'Failed to fetch from LoTW', total: 0, added: 0, updated: 0 };
|
||||
}
|
||||
|
||||
// If adifQSOs is an error object, throw it
|
||||
if (adifQSOs.error) {
|
||||
throw new Error(adifQSOs.error);
|
||||
}
|
||||
|
||||
if (adifQSOs.length === 0) {
|
||||
return { success: true, total: 0, added: 0, updated: 0, message: 'No QSOs found in LoTW' };
|
||||
}
|
||||
|
||||
if (jobId) {
|
||||
await updateJobProgress(jobId, {
|
||||
message: `Processing ${adifQSOs.length} QSOs...`,
|
||||
step: 'process',
|
||||
total: adifQSOs.length,
|
||||
processed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
let updatedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const qsoData of adifQSOs) {
|
||||
for (let i = 0; i < adifQSOs.length; i++) {
|
||||
const qsoData = adifQSOs[i];
|
||||
|
||||
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))
|
||||
.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);
|
||||
|
||||
if (existing.length > 0) {
|
||||
@@ -299,13 +319,21 @@ async function syncQSOs(userId, lotwUsername, lotwPassword, sinceDate = null) {
|
||||
await db.insert(qsos).values(dbQSO);
|
||||
addedCount++;
|
||||
}
|
||||
|
||||
// Update job progress every 10 QSOs
|
||||
if (jobId && (i + 1) % 10 === 0) {
|
||||
await updateJobProgress(jobId, {
|
||||
processed: i + 1,
|
||||
message: `Processed ${i + 1}/${adifQSOs.length} QSOs...`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing QSO', { error: error.message, qso: qsoData });
|
||||
logger.error('Error processing QSO', { error: error.message, jobId, qso: qsoData });
|
||||
errors.push({ qso: qsoData, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount });
|
||||
logger.info('LoTW sync completed', { total: adifQSOs.length, added: addedCount, updated: updatedCount, jobId });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -383,7 +411,7 @@ export async function getQSOStats(userId) {
|
||||
/**
|
||||
* Get the date of the last LoTW QSL for a user
|
||||
*/
|
||||
async function getLastLoTWQSLDate(userId) {
|
||||
export async function getLastLoTWQSLDate(userId) {
|
||||
const [result] = await db
|
||||
.select({ maxDate: max(qsos.lotwQslRdate) })
|
||||
.from(qsos)
|
||||
@@ -401,101 +429,6 @@ async function getLastLoTWQSLDate(userId) {
|
||||
return new Date(`${year}-${month}-${day}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* LoTW sync job processor for the job queue
|
||||
*/
|
||||
export async function syncQSOsForJob(jobId, userId, data) {
|
||||
const { lotwUsername, lotwPassword } = data;
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
message: 'Fetching QSOs from LoTW...',
|
||||
step: 'fetch',
|
||||
});
|
||||
|
||||
const lastQSLDate = await getLastLoTWQSLDate(userId);
|
||||
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`);
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
message: `Processing ${adifQSOs.length} QSOs...`,
|
||||
step: 'process',
|
||||
total: adifQSOs.length,
|
||||
processed: 0,
|
||||
});
|
||||
|
||||
let addedCount = 0;
|
||||
let updatedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < adifQSOs.length; i++) {
|
||||
const qsoData = adifQSOs[i];
|
||||
|
||||
try {
|
||||
const dbQSO = convertQSODatabaseFormat(qsoData, userId);
|
||||
|
||||
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);
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -504,5 +437,3 @@ export async function deleteQSOs(userId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Register the LoTW sync processor with the job queue
|
||||
registerProcessor('lotw_sync', syncQSOsForJob);
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* API base URL - configurable via environment variable
|
||||
* Falls back to relative path for same-domain deployment
|
||||
*/
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
/**
|
||||
* Make an API request
|
||||
* @param {string} endpoint - API endpoint (e.g., '/auth/login')
|
||||
* @param {Object} options - Fetch options
|
||||
* @returns {Promise<Object>} Response data
|
||||
*/
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
// Get token from localStorage (only in browser)
|
||||
// Get token from localStorage
|
||||
let token = null;
|
||||
if (browser) {
|
||||
try {
|
||||
token = localStorage.getItem('auth_token');
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +21,7 @@ async function apiRequest(endpoint, options = {}) {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -45,135 +31,50 @@ async function apiRequest(endpoint, options = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication API
|
||||
*/
|
||||
// Authentication API
|
||||
export const authAPI = {
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User email
|
||||
* @param {string} userData.password - User password
|
||||
* @param {string} userData.callsign - Ham radio callsign
|
||||
* @returns {Promise<Object>} Registration response with token and user
|
||||
*/
|
||||
register: (userData) =>
|
||||
apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData),
|
||||
}),
|
||||
register: (userData) => apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* @param {Object} credentials - Login credentials
|
||||
* @param {string} credentials.email - User email
|
||||
* @param {string} credentials.password - User password
|
||||
* @returns {Promise<Object>} Login response with token and user
|
||||
*/
|
||||
login: (credentials) =>
|
||||
apiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
}),
|
||||
login: (credentials) => apiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credentials),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* @returns {Promise<Object>} User profile
|
||||
*/
|
||||
getProfile: () => apiRequest('/auth/me'),
|
||||
|
||||
/**
|
||||
* Update LoTW credentials
|
||||
* @param {Object} credentials - LoTW credentials
|
||||
* @param {string} credentials.lotwUsername - LoTW username
|
||||
* @param {string} credentials.lotwPassword - LoTW password
|
||||
* @returns {Promise<Object>} Update response
|
||||
*/
|
||||
updateLoTWCredentials: (credentials) =>
|
||||
apiRequest('/auth/lotw-credentials', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(credentials),
|
||||
}),
|
||||
updateLoTWCredentials: (credentials) => apiRequest('/auth/lotw-credentials', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(credentials),
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Awards API
|
||||
*/
|
||||
// Awards API
|
||||
export const awardsAPI = {
|
||||
/**
|
||||
* Get all available awards
|
||||
* @returns {Promise<Object>} List of awards
|
||||
*/
|
||||
getAll: () => apiRequest('/awards'),
|
||||
|
||||
/**
|
||||
* Get user progress for a specific award
|
||||
* @param {string} awardId - Award ID
|
||||
* @returns {Promise<Object>} Award progress
|
||||
*/
|
||||
getProgress: (awardId) => apiRequest(`/awards/${awardId}/progress`),
|
||||
getEntities: (awardId) => apiRequest(`/awards/${awardId}/entities`),
|
||||
};
|
||||
|
||||
/**
|
||||
* QSOs API
|
||||
*/
|
||||
// QSOs API
|
||||
export const qsosAPI = {
|
||||
/**
|
||||
* Get user's QSOs
|
||||
* @param {Object} filters - Query filters
|
||||
* @returns {Promise<Object>} List of QSOs
|
||||
*/
|
||||
getAll: (filters = {}) => {
|
||||
const params = new URLSearchParams(filters);
|
||||
return apiRequest(`/qsos?${params}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get QSO statistics
|
||||
* @returns {Promise<Object>} QSO statistics
|
||||
*/
|
||||
getStats: () => apiRequest('/qsos/stats'),
|
||||
|
||||
/**
|
||||
* Sync QSOs from LoTW (queues a job)
|
||||
* @returns {Promise<Object>} Job information
|
||||
*/
|
||||
syncFromLoTW: () =>
|
||||
apiRequest('/lotw/sync', {
|
||||
method: 'POST',
|
||||
}),
|
||||
syncFromLoTW: () => apiRequest('/lotw/sync', { method: 'POST' }),
|
||||
|
||||
/**
|
||||
* Delete all QSOs for authenticated user
|
||||
* @returns {Promise<Object>} Delete result
|
||||
*/
|
||||
deleteAll: () =>
|
||||
apiRequest('/qsos/all', {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteAll: () => apiRequest('/qsos/all', { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Jobs API
|
||||
*/
|
||||
// Jobs API
|
||||
export const jobsAPI = {
|
||||
/**
|
||||
* Get job status
|
||||
* @param {number} jobId - Job ID
|
||||
* @returns {Promise<Object>} Job status
|
||||
*/
|
||||
getStatus: (jobId) => apiRequest(`/jobs/${jobId}`),
|
||||
|
||||
/**
|
||||
* Get user's active job
|
||||
* @returns {Promise<Object>} Active job or null
|
||||
*/
|
||||
getActive: () => apiRequest('/jobs/active'),
|
||||
|
||||
/**
|
||||
* Get user's recent jobs
|
||||
* @param {number} limit - Maximum number of jobs to return
|
||||
* @returns {Promise<Object>} List of jobs
|
||||
*/
|
||||
getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`),
|
||||
};
|
||||
|
||||
38
src/frontend/src/lib/components/BackButton.svelte
Normal file
38
src/frontend/src/lib/components/BackButton.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
export let href = '/';
|
||||
export let text = '← Back';
|
||||
export let secondary = false;
|
||||
</script>
|
||||
|
||||
<a href={href} class="back-button" class:secondary={secondary}>
|
||||
{text}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.back-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.back-button.secondary {
|
||||
background-color: transparent;
|
||||
color: #4a90e2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.back-button.secondary:hover {
|
||||
text-decoration: underline;
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
36
src/frontend/src/lib/components/ErrorDisplay.svelte
Normal file
36
src/frontend/src/lib/components/ErrorDisplay.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
export let error;
|
||||
export let backLink = null;
|
||||
export let backText = '← Back';
|
||||
</script>
|
||||
|
||||
<div class="error">
|
||||
<p>Failed to load: {error}</p>
|
||||
{#if backLink}
|
||||
<a href={backLink} class="btn">{backText}</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.1rem;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #357abd;
|
||||
}
|
||||
</style>
|
||||
16
src/frontend/src/lib/components/Loading.svelte
Normal file
16
src/frontend/src/lib/components/Loading.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
export let message = 'Loading...';
|
||||
</script>
|
||||
|
||||
<div class="loading">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -3,219 +3,107 @@ import { browser } from '$app/environment';
|
||||
import { authAPI } from './api.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthState
|
||||
* @property {Object|null} user - Current user object
|
||||
* @property {string|null} token - JWT token
|
||||
* @property {boolean} loading - Loading state
|
||||
* @property {string|null} error - Error message
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely get item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @returns {string|null} Storage value or null
|
||||
*/
|
||||
function getStorageItem(key) {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely set item in localStorage
|
||||
* @param {string} key - Storage key
|
||||
* @param {string} value - Storage value
|
||||
*/
|
||||
function setStorageItem(key, value) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely remove item from localStorage
|
||||
* @param {string} key - Storage key
|
||||
*/
|
||||
function removeStorageItem(key) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authentication store
|
||||
* @returns {import('svelte/store').Writable<AuthState>}
|
||||
* Authentication store
|
||||
* Manages user authentication state and localStorage persistence
|
||||
*/
|
||||
function createAuthStore() {
|
||||
// Initialize state (localStorage only accessed in browser)
|
||||
let initialState = {
|
||||
// Initialize state from localStorage
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Only read from localStorage if in browser
|
||||
if (browser) {
|
||||
const token = getStorageItem('auth_token');
|
||||
const userJson = getStorageItem('auth_user');
|
||||
initialState = {
|
||||
user: userJson ? JSON.parse(userJson) : null,
|
||||
token: token || null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const userJson = localStorage.getItem('auth_user');
|
||||
initialState.token = token;
|
||||
initialState.user = userJson ? JSON.parse(userJson) : null;
|
||||
} catch {
|
||||
// localStorage unavailable - continue with empty state
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('svelte/store').Writable<AuthState>} */
|
||||
const { subscribe, set, update } = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email
|
||||
* @param {string} userData.password
|
||||
* @param {string} userData.callsign
|
||||
*/
|
||||
register: async (userData) => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
update((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const response = await authAPI.register(userData);
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('auth_token', response.token);
|
||||
setStorageItem('auth_user', JSON.stringify(response.user));
|
||||
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(response.user));
|
||||
}
|
||||
set({ user: response.user, token: response.token, loading: false, error: null });
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message,
|
||||
}));
|
||||
update((s) => ({ ...s, loading: false, error: error.message }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Login user
|
||||
* @param {string} email - User email
|
||||
* @param {string} password - User password
|
||||
*/
|
||||
login: async (email, password) => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
update((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const response = await authAPI.login({ email, password });
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('auth_token', response.token);
|
||||
setStorageItem('auth_user', JSON.stringify(response.user));
|
||||
|
||||
set({
|
||||
user: response.user,
|
||||
token: response.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(response.user));
|
||||
}
|
||||
set({ user: response.user, token: response.token, loading: false, error: null });
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message,
|
||||
}));
|
||||
update((s) => ({ ...s, loading: false, error: error.message }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
logout: () => {
|
||||
removeStorageItem('auth_token');
|
||||
removeStorageItem('auth_user');
|
||||
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
if (browser) {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
set({ user: null, token: null, loading: false, error: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Load user profile from API
|
||||
*/
|
||||
loadProfile: async () => {
|
||||
const token = getStorageItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
update((state) => ({ ...state, loading: true }));
|
||||
if (!browser) return;
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const response = await authAPI.getProfile();
|
||||
|
||||
setStorageItem('auth_user', JSON.stringify(response.user));
|
||||
|
||||
update((state) => ({
|
||||
...state,
|
||||
user: response.user,
|
||||
loading: false,
|
||||
}));
|
||||
if (browser) {
|
||||
localStorage.setItem('auth_user', JSON.stringify(response.user));
|
||||
}
|
||||
update((s) => ({ ...s, user: response.user, loading: false }));
|
||||
} catch (error) {
|
||||
// If token is invalid, logout
|
||||
if (error.message.includes('Unauthorized')) {
|
||||
removeStorageItem('auth_token');
|
||||
removeStorageItem('auth_user');
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
if (browser) {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
set({ user: null, token: null, loading: false, error: null });
|
||||
} else {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error.message,
|
||||
}));
|
||||
update((s) => ({ ...s, loading: false, error: error.message }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError: () => {
|
||||
update((state) => ({ ...state, error: null }));
|
||||
update((s) => ({ ...s, error: null }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication store
|
||||
* @type {ReturnType<typeof createAuthStore>}
|
||||
*/
|
||||
export const auth = createAuthStore();
|
||||
|
||||
Reference in New Issue
Block a user