diff --git a/bun.lock b/bun.lock index 5872535..99774a8 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index d63de8c..0d4afc7 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/backend/config.js b/src/backend/config.js new file mode 100644 index 0000000..30bf0bb --- /dev/null +++ b/src/backend/config.js @@ -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(); +} diff --git a/src/backend/index.js b/src/backend/index.js index b2f8843..1e8ba83 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -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 { diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index 7bf448c..572d89e 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -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} 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} True if password matches */ async function verifyPassword(password, hash) { - return bcrypt.compare(password, hash); + return Bun.password.verify(password, hash); } /** diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index b4413c6..02ea817 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -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); } diff --git a/src/backend/services/job-queue.service.js b/src/backend/services/job-queue.service.js index 7ad0e60..143ed9e 100644 --- a/src/backend/services/job-queue.service.js +++ b/src/backend/services/job-queue.service.js @@ -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} 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} 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) => { diff --git a/src/backend/services/lotw.service.js b/src/backend/services/lotw.service.js index 68c0a21..3401ee8 100644 --- a/src/backend/services/lotw.service.js +++ b/src/backend/services/lotw.service.js @@ -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); diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index 31ff0ed..e5a3ba2 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -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} 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} 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} 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} 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} 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} List of awards - */ getAll: () => apiRequest('/awards'), - - /** - * Get user progress for a specific award - * @param {string} awardId - Award ID - * @returns {Promise} 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} List of QSOs - */ getAll: (filters = {}) => { const params = new URLSearchParams(filters); return apiRequest(`/qsos?${params}`); }, - /** - * Get QSO statistics - * @returns {Promise} QSO statistics - */ getStats: () => apiRequest('/qsos/stats'), - /** - * Sync QSOs from LoTW (queues a job) - * @returns {Promise} Job information - */ - syncFromLoTW: () => - apiRequest('/lotw/sync', { - method: 'POST', - }), + syncFromLoTW: () => apiRequest('/lotw/sync', { method: 'POST' }), - /** - * Delete all QSOs for authenticated user - * @returns {Promise} 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} Job status - */ getStatus: (jobId) => apiRequest(`/jobs/${jobId}`), - - /** - * Get user's active job - * @returns {Promise} Active job or null - */ getActive: () => apiRequest('/jobs/active'), - - /** - * Get user's recent jobs - * @param {number} limit - Maximum number of jobs to return - * @returns {Promise} List of jobs - */ getRecent: (limit = 10) => apiRequest(`/jobs?limit=${limit}`), }; diff --git a/src/frontend/src/lib/components/BackButton.svelte b/src/frontend/src/lib/components/BackButton.svelte new file mode 100644 index 0000000..23ed783 --- /dev/null +++ b/src/frontend/src/lib/components/BackButton.svelte @@ -0,0 +1,38 @@ + + + + {text} + + + diff --git a/src/frontend/src/lib/components/ErrorDisplay.svelte b/src/frontend/src/lib/components/ErrorDisplay.svelte new file mode 100644 index 0000000..76704a8 --- /dev/null +++ b/src/frontend/src/lib/components/ErrorDisplay.svelte @@ -0,0 +1,36 @@ + + +
+

Failed to load: {error}

+ {#if backLink} + {backText} + {/if} +
+ + diff --git a/src/frontend/src/lib/components/Loading.svelte b/src/frontend/src/lib/components/Loading.svelte new file mode 100644 index 0000000..20eaa4a --- /dev/null +++ b/src/frontend/src/lib/components/Loading.svelte @@ -0,0 +1,16 @@ + + +
+

{message}

+
+ + diff --git a/src/frontend/src/lib/stores.js b/src/frontend/src/lib/stores.js index b75b75b..20ceb06 100644 --- a/src/frontend/src/lib/stores.js +++ b/src/frontend/src/lib/stores.js @@ -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} + * 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} */ 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} - */ export const auth = createAuthStore();