From 1d341c3a7ccf289d91b7b13152f1bad0feb6d78b Mon Sep 17 00:00:00 2001 From: NurAlan Date: Tue, 14 Oct 2025 11:16:23 +0700 Subject: [PATCH] feat: integration with API Vega Cloud --- .env.example | 9 +- bun.lock | 43 ++++++ package.json | 1 + src/app/router.ts | 4 - src/config/index.ts | 15 +- src/helpers/http/axios.ts | 191 +++++++++++++++++++++++++ src/helpers/signature.ts | 10 +- src/middlewares/deviceGuard.ts | 2 +- src/modules/auth/commands/service.ts | 40 ------ src/modules/auth/router.ts | 38 ----- src/modules/auth/schema.ts | 41 ------ src/modules/device/commands/command.ts | 19 +++ src/modules/device/commands/service.ts | 17 ++- src/modules/device/queries/query.ts | 43 ++++++ src/modules/device/queries/service.ts | 49 +++++++ src/modules/device/router.ts | 33 ++++- src/modules/device/schema.ts | 60 ++++++++ src/modules/user/commands/command.ts | 27 ---- src/modules/user/commands/service.ts | 76 ---------- src/modules/user/queries/query.ts | 52 ------- src/modules/user/queries/service.ts | 8 -- src/modules/user/router.ts | 87 ----------- src/modules/user/schema.ts | 34 ----- 23 files changed, 468 insertions(+), 431 deletions(-) create mode 100644 src/helpers/http/axios.ts delete mode 100644 src/modules/auth/commands/service.ts delete mode 100644 src/modules/auth/router.ts delete mode 100644 src/modules/auth/schema.ts create mode 100644 src/modules/device/commands/command.ts create mode 100644 src/modules/device/queries/query.ts create mode 100644 src/modules/device/queries/service.ts delete mode 100644 src/modules/user/commands/command.ts delete mode 100644 src/modules/user/commands/service.ts delete mode 100644 src/modules/user/queries/query.ts delete mode 100644 src/modules/user/queries/service.ts delete mode 100644 src/modules/user/router.ts delete mode 100644 src/modules/user/schema.ts diff --git a/.env.example b/.env.example index 8bd93bb..3101a34 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,6 @@ DB_DSN=postgresql://local:secret@127.0.0.1/mydb BASIC_AUTH_USERNAME=local BASIC_AUTH_PASSWORD=secret -# Attendance -ATTENDANCE_CHECK_IN_HOUR=8 - # Object Storage S3_URL= S3_ACCESS_KEY= @@ -23,3 +20,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319/v1/traces JWT_PUBLIC_KEY= JWT_PRIVATE_KEY= JWT_SECRET= + +# 3d Party REST API +HTTP_BASE_URL=http://example.com +HTTP_USERNAME= +HTTP_PASSWORD= +HTTP_TIMEOUT=3000 \ No newline at end of file diff --git a/bun.lock b/bun.lock index 90222c6..4a32064 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@opentelemetry/sdk-node": "^0.205.0", "@opentelemetry/sdk-trace-node": "^2.1.0", "@types/pg": "^8.15.5", + "axios": "^1.12.2", "date-fns": "^4.1.0", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.5", @@ -610,8 +611,12 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -628,6 +633,8 @@ "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -644,6 +651,8 @@ "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], @@ -660,6 +669,8 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], "drizzle-orm": ["drizzle-orm@0.44.5", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ=="], @@ -668,6 +679,8 @@ "drizzle-zod": ["drizzle-zod@0.8.3", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "elysia": ["elysia@1.4.6", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "openapi-types": ">= 12.0.0" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-u2CorXLPs5ZXyWP+tQR+bgka/lJA4vNpB8lDE2w/sTmdaIwoPQmHEL4J3ai6OAlluWR1kfG7T9gO3EYT9D8viQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -676,6 +689,14 @@ "error": ["error@7.0.2", "", { "dependencies": { "string-template": "~0.2.1", "xtend": "~4.0.0" } }, "sha512-UtVv4l5MhijsYUxPJo4390gzfZvAnTHreNnDjnTZaKIiZ/SemXxAhBkYSKtWa5RtBXbLP8tMgn/n0RUa/H7jXw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -738,6 +759,10 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -750,6 +775,10 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -758,12 +787,18 @@ "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-server-elysia": ["graceful-server-elysia@1.0.25", "", { "peerDependencies": { "elysia": "latest" } }, "sha512-4IkmTnXHttsXMk6az39Z2O3aMBXw+p7SLZtwmJfCc7FCwoGzgO8F64wy9aY8ERUrAaQGV1a9kEM9sKTKo7qCvg=="], "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], @@ -832,10 +867,16 @@ "long": ["long@2.4.0", "", {}, "sha512-ijUtjmO/n2A5PaosNG9ZGDsQ3vxJg7ZW8vsY8Kp0f2yIZWhSJvjmegV7t+9RPQKxKrvj8yKGehhS+po14hPLGQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -918,6 +959,8 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], diff --git a/package.json b/package.json index ec9eeba..14371db 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@opentelemetry/sdk-node": "^0.205.0", "@opentelemetry/sdk-trace-node": "^2.1.0", "@types/pg": "^8.15.5", + "axios": "^1.12.2", "date-fns": "^4.1.0", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.5", diff --git a/src/app/router.ts b/src/app/router.ts index f529a96..87091fa 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -1,13 +1,9 @@ import { Elysia } from 'elysia' -// import authRouter from '~/modules/auth/router' -// import userRouter from '~/modules/user/router' import deviceRouter from '~/modules/device/router' export const router = new Elysia({ name: 'codebase.router', }) - // .use(authRouter) - // .use(userRouter) .use(deviceRouter) .get('/', () => ({ message: 'This service is running as expected.', diff --git a/src/config/index.ts b/src/config/index.ts index 555b204..b92d2bf 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -61,12 +61,9 @@ export const redis = { db: Number(process.env.REDIS_DB), } -// const minioHost = process.env.MINIO_HOST || '' -// export const minio = { -// endPoint: minioHost, -// port: Number(process.env.MINIO_PORT || 9000), -// useSSL: !!(process.env.MINIO_SSL || false), -// accessKey: String(process.env.MINIO_ACCESS_KEY) || '', -// secretKey: process.env.MINIO_SECRET_KEY || '', -// } -// export const uploadBucket = process.env.MINIO_BUCKET || '' +export const httpConfig = { + baseUrl: process.env.HTTP_BASE_URL || 'default-url', + username: process.env.HTTP_USERNAME, + password: process.env.HTTP_PASSWORD, + timeout: Number(process.env.HTTP_TIMEOUT) || 3000, +} diff --git a/src/helpers/http/axios.ts b/src/helpers/http/axios.ts new file mode 100644 index 0000000..356e541 --- /dev/null +++ b/src/helpers/http/axios.ts @@ -0,0 +1,191 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import { httpConfig } from '~/config' +import logger from '~/plugins/logger' + +interface AxiosConfigWithMetadata extends AxiosRequestConfig { + metadata?: { + startTime: number + } +} + +interface HttpRequestOptions { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + url: string + body?: Record + config?: AxiosRequestConfig +} + +interface HttpResponse { + data: T + status: number + statusText: string + headers: Record +} + +class HttpError extends Error { + constructor( + message: string, + public status: number, + public response?: AxiosResponse, + ) { + super(message) + this.name = 'HttpError' + } +} + +let axiosInstance: AxiosInstance | null = null + +function getAxiosInstance(): AxiosInstance { + if (!axiosInstance) { + axiosInstance = axios.create({ + baseURL: httpConfig.baseUrl, + timeout: 30000, // 30 seconds default timeout + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }) + + // Add basic auth if configured + if (httpConfig.username && httpConfig.password) { + const auth = Buffer.from(`${httpConfig.username}:${httpConfig.password}`).toString('base64') + axiosInstance.defaults.headers.common['Authorization'] = `Basic ${auth}` + } + + // Request interceptor for logging + axiosInstance.interceptors.request.use( + (config) => { + ;(config as AxiosConfigWithMetadata).metadata = { startTime: Date.now() } + logger.info({ + msg: 'HTTP Request', + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + headers: config.headers, + params: config.params, + data: config.data, + }) + return config + }, + (error) => { + logger.error({ + msg: 'HTTP Request Error', + error: error.message, + config: { + method: error.config?.method, + url: error.config?.url, + baseURL: error.config?.baseURL, + }, + }) + return Promise.reject(error) + }, + ) + + // Response interceptor for logging + axiosInstance.interceptors.response.use( + (response) => { + const startTime = (response.config as AxiosConfigWithMetadata).metadata?.startTime + logger.info({ + msg: 'HTTP Response', + status: response.status, + statusText: response.statusText, + method: response.config.method?.toUpperCase(), + url: response.config.url, + baseURL: response.config.baseURL, + responseTime: startTime ? Date.now() - startTime : undefined, + }) + return response + }, + (error) => { + const startTime = (error.config as AxiosConfigWithMetadata)?.metadata?.startTime + logger.error({ + msg: 'HTTP Response Error', + status: error.response?.status, + statusText: error.response?.statusText, + method: error.config?.method?.toUpperCase(), + url: error.config?.url, + baseURL: error.config?.baseURL, + error: error.message, + responseData: error.response?.data, + responseTime: startTime ? Date.now() - startTime : undefined, + }) + return Promise.reject(error) + }, + ) + } + + return axiosInstance +} + +async function httpRequest(options: HttpRequestOptions): Promise> { + const { method, url, body, config = {} } = options + const instance = getAxiosInstance() + + const axiosConfig: AxiosRequestConfig = { + method, + url, + ...config, + } + + if (body && ['POST', 'PUT', 'PATCH'].includes(method)) { + axiosConfig.data = body + } + + if (method === 'GET' && body) { + axiosConfig.params = body + } + + const maxRetries = 3 + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response: AxiosResponse = await instance.request(axiosConfig) + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as Record, + } + } catch (error) { + lastError = error as Error + + // Check if it's a transient error worth retrying + const isRetryable = axios.isAxiosError(error) + && ( + !error.response // Network errors + || error.response.status >= 500 // Server errors + || error.response.status === 429 // Rate limiting + ) + + if (!isRetryable || attempt === maxRetries) { + break + } + + // Exponential backoff + const delay = Math.pow(2, attempt) * 1000 + logger.warn({ + msg: 'HTTP Retry', + attempt, + maxRetries, + delay, + method, + url, + error: lastError.message, + }) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + + // If we get here, all retries failed + if (lastError && axios.isAxiosError(lastError)) { + const status = lastError.response?.status || 0 + const message = lastError.response?.data?.message || lastError.message + throw new HttpError(`HTTP ${method} ${url} failed: ${message}`, status, lastError.response) + } + + throw new HttpError(`HTTP ${method} ${url} failed: ${lastError?.message || 'Unknown error'}`, 0) +} + +export { httpRequest, HttpError } +export type { HttpRequestOptions, HttpResponse } diff --git a/src/helpers/signature.ts b/src/helpers/signature.ts index daec260..53a34af 100644 --- a/src/helpers/signature.ts +++ b/src/helpers/signature.ts @@ -132,13 +132,9 @@ console.log(sign({ deviceType: 'A', commandType: 'C', towerNumber: 'T1', - timestamp: '2025-10-01T14:30:12Z', - nonce: '9f2a6e7c8d3b', - payload: { - AC: 'On', - Temp: 25, - }, -}, 'savy_123123')) + timestamp: '2025-10-14T04:06:52.022Z', + nonce: '50ce476cd1c355ae552ae18be55af72c', +}, '458fd644689a456efa87d38f788511d9')) /** * Verifies the signature of the data using HMAC-SHA256 with constant-time comparison. diff --git a/src/middlewares/deviceGuard.ts b/src/middlewares/deviceGuard.ts index a060222..7313359 100644 --- a/src/middlewares/deviceGuard.ts +++ b/src/middlewares/deviceGuard.ts @@ -1,5 +1,5 @@ // Clock skew window: ±5 minutes -const CLOCK_SKEW_MS = 5 * 60 * 1000 +// const CLOCK_SKEW_MS = 5 * 60 * 1000 export const deviceGuard = () => ({ beforeHandle(context: unknown) { diff --git a/src/modules/auth/commands/service.ts b/src/modules/auth/commands/service.ts deleted file mode 100644 index 27b24a7..0000000 --- a/src/modules/auth/commands/service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { UnauthenticatedError } from '~/helpers/errors' -import { LoginBody, LoginResponse } from '../schema' -import UserQuery from '@/user/queries/query' -import { passwordVerify } from '~/helpers/utils' -import { logger } from '~/plugins' -import { isEmpty, omit } from 'lodash-es' -import { sign } from '~/helpers/jwt' -import { UserInfo } from '../schema' - -export default abstract class CommandService { - static async login(body: LoginBody): Promise { - const user = await UserQuery.getUserByEmail(body.email) - if (!user) { - logger.debug({ - 'body.email': body.email, - }, 'Login failed due to user not found.') - throw new UnauthenticatedError('Invalid username or password.') - } - - if (!isEmpty(user.password) && !passwordVerify(body.password, String(user.password))) { - logger.info({ - 'user.hasPassword': !isEmpty(user.password), - }, 'Login failed due to invalid password.') - throw new UnauthenticatedError('Invalid username or password.') - } - - const docs = omit(user, ['password', 'deletedAt']) as UserInfo - - const now = Date.now() - const sub = `${user.id}.${now.toString(36).toUpperCase()}` - const accessToken = await sign({ - sub, - user: docs, - }) - - return { - accessToken, - } - } -} diff --git a/src/modules/auth/router.ts b/src/modules/auth/router.ts deleted file mode 100644 index 4c47435..0000000 --- a/src/modules/auth/router.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Elysia } from 'elysia' -import { basicAuthMiddleware, bearerAuthMiddleware } from '~/middlewares' -import { getInfoSchema, loginBody, loginResponseSchema } from './schema' -import CommandService from './commands/service' - -export const router = new Elysia({ - name: 'modules.auth', - detail: { tags: ['Authentication'] }, - prefix: '/auth/v1', -}) - .post('/login', async ({ headers, set, body }) => { - // basic auth middleware - basicAuthMiddleware(headers, set) - - const user = await CommandService.login(body) - return { - message: 'Logged in.', - data: user, - } - }, { - body: loginBody, - response: loginResponseSchema, - detail: { - security: [{ basicAuth: [] }], - }, - }) - .get('/info', async ({ headers, set }) => { - // bearer auth middleware - const user = await bearerAuthMiddleware(headers, set) - return { - message: 'Get user info.', - data: user, - } - }, { - response: getInfoSchema, - }) - -export default router diff --git a/src/modules/auth/schema.ts b/src/modules/auth/schema.ts deleted file mode 100644 index c694bfd..0000000 --- a/src/modules/auth/schema.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from 'zod' -import { selectUserSchema } from '../user/schema' - -export const loginBody = z.object({ - email: z.string().email(), - password: z.string().min(8), -}) -export type LoginBody = z.infer - -const loginResponse = z.object({ - accessToken: z.string().describe('Access token of the user, encoded in JWT format'), - // refreshToken: z.string().describe('Refresh token of the user, encoded in JWT format'), -}) -export type LoginResponse = z.infer - -const userInfo = selectUserSchema.omit({ - password: true, - deletedAt: true, -}) -export const getInfoResponse = userInfo -export type UserInfo = z.infer - -export const loginResponseSchema = { - 200: z.object({ - message: z.literal('Logged in.'), - data: loginResponse, - }), - 401: z.object({ - message: z.string(), - }), -} - -export const getInfoSchema = { - 200: z.object({ - message: z.literal('Get user info.'), - data: getInfoResponse, - }), - 401: z.object({ - message: z.string(), - }), -} diff --git a/src/modules/device/commands/command.ts b/src/modules/device/commands/command.ts new file mode 100644 index 0000000..d8b2f2e --- /dev/null +++ b/src/modules/device/commands/command.ts @@ -0,0 +1,19 @@ +import { httpRequest } from '~/helpers/http/axios' +import logger from '~/plugins/logger' + +export async function sendCommandToThirdParty(topicData: string, payload: string): Promise { + try { + const response = await httpRequest({ + method: 'POST', + url: '/command', + body: { + topic: topicData, + payload, + }, + }) + logger.info({ msg: 'Third-party command sent successfully', topic: topicData, status: response.status }) + } catch (error) { + logger.error({ msg: 'Failed to send command to third-party service', topic: topicData, error: (error as Error).message }) + throw new Error('third_party_command_failed') + } +} diff --git a/src/modules/device/commands/service.ts b/src/modules/device/commands/service.ts index 245479b..2067cfa 100644 --- a/src/modules/device/commands/service.ts +++ b/src/modules/device/commands/service.ts @@ -1,5 +1,6 @@ import { verify } from '~/helpers/signature' import { DeviceCommandBody } from '../schema' +import { sendCommandToThirdParty } from './command' export default abstract class CommandService { static async sendCommand(body: DeviceCommandBody, headers: Record): Promise<{ @@ -22,10 +23,20 @@ export default abstract class CommandService { // TODO: Check nonce for replay attack when DB is implemented - return { - status: 'success', - message: 'Command accepted', + const result = { + status: 'success' as const, + message: 'Command accepted' as const, receivedAt: new Date().toISOString(), } + + // Generate topicData and send to third-party service + try { + const topicData = `${body.merchantName}/${body.floorName}_${body.unitNumber}-${body.deviceName}-${body.roomName}-${body.deviceType}-${body.commandType}-${body.towerNumber}` + await sendCommandToThirdParty(topicData, body.payload.AC) + } catch { + throw new Error('internal_error') + } + + return result } } diff --git a/src/modules/device/queries/query.ts b/src/modules/device/queries/query.ts new file mode 100644 index 0000000..0a18580 --- /dev/null +++ b/src/modules/device/queries/query.ts @@ -0,0 +1,43 @@ +import { httpRequest } from '~/helpers/http/axios' +import logger from '~/plugins/logger' + +export async function queryDeviceStatus(topic: string): Promise<{ + floor: string + unit: string + device: string + room: string + devType: string + code: string + tower: string + payload: string +} | null> { + try { + const response = await httpRequest({ + method: 'GET', + url: '/state-reply', + body: { topic }, + }) + + if ((response.data as Record) && (response.data as Record).data) { + const thirdPartyData = (response.data as Record).data as Record + logger.info({ msg: 'Device status queried successfully', topic, status: response.status }) + + return { + floor: thirdPartyData.floor as string, + unit: thirdPartyData.unit as string, + device: thirdPartyData.device as string, + room: thirdPartyData.room as string, + devType: thirdPartyData.devType as string, + code: thirdPartyData.code as string, + tower: thirdPartyData.tower as string, + payload: thirdPartyData.payload as string, + } + } else { + logger.info({ msg: 'Device status not found', topic, status: response.status }) + return null + } + } catch (error) { + logger.error({ msg: 'Failed to query device status', topic, error: (error as Error).message }) + throw new Error('third_party_query_failed') + } +} diff --git a/src/modules/device/queries/service.ts b/src/modules/device/queries/service.ts new file mode 100644 index 0000000..45165f3 --- /dev/null +++ b/src/modules/device/queries/service.ts @@ -0,0 +1,49 @@ +import { verify } from '~/helpers/signature' +import { DeviceQueryParams } from '../schema' +import { queryDeviceStatus } from './query' + +export default abstract class QueryService { + static async getDeviceStatus(query: DeviceQueryParams, headers: Record): Promise<{ + status: 'success' + message: 'command accepted' + data: { + floor: string + unit: string + device: string + room: string + devType: string + code: string + tower: string + payload: string + } + } | null> { + const apiKey = headers['x-api-key'] + + // For now, use apiKey as secretKey since DB validation is skipped + const secretKey = apiKey + + // Remove signature from query for verification + const { signature, ...queryWithoutSignature } = query + + const isValid = verify(queryWithoutSignature, signature, secretKey) + if (!isValid) { + throw new Error('invalid_signature') + } + + // TODO: Check nonce for replay attack when DB is implemented + + const topic = `${query.merchantName}/${query.floorName}_${query.unitNumber}-${query.deviceName}-${query.roomName}-${query.deviceType}-${query.commandType}-${query.towerNumber}` + + const deviceData = await queryDeviceStatus(topic) + + if (deviceData) { + return { + status: 'success', + message: 'command accepted', + data: deviceData, + } + } + + return null + } +} diff --git a/src/modules/device/router.ts b/src/modules/device/router.ts index b8c5b86..9252e8d 100644 --- a/src/modules/device/router.ts +++ b/src/modules/device/router.ts @@ -1,7 +1,8 @@ import { Elysia } from 'elysia' import { deviceGuard } from '~/middlewares/deviceGuard' -import { deviceCommandBody, deviceCommandResponseSchema } from './schema' +import { deviceCommandBody, deviceCommandResponseSchema, deviceQueryParams, deviceQueryResponseSchema } from './schema' import CommandService from './commands/service' +import QueryService from './queries/service' import { deviceErrorResponseMap } from '~/helpers/errors' export const router = new Elysia({ @@ -35,5 +36,35 @@ export const router = new Elysia({ ], }, }) + .get('/status', async ({ query, headers, set }) => { + try { + const result = await QueryService.getDeviceStatus(query, headers) + if (result) { + return result + } else { + set.status = 404 + return { status: 'not_found', message: 'Device status not found' } + } + } catch (error) { + if (error instanceof Error && error.message in deviceErrorResponseMap) { + const { status, response } = deviceErrorResponseMap[error.message] + set.status = status + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return response as any + } + set.status = 500 + return { status: 'internal_error', message: 'Server error' } + } + }, { + query: deviceQueryParams, + response: deviceQueryResponseSchema, + detail: { + security: [ + { apiKey: [] }, + { timestamp: [] }, + { signature: [] }, + ], + }, + }) export default router diff --git a/src/modules/device/schema.ts b/src/modules/device/schema.ts index 87d87c9..a82c030 100644 --- a/src/modules/device/schema.ts +++ b/src/modules/device/schema.ts @@ -20,6 +20,22 @@ export const deviceCommandBody = z.object({ }) export type DeviceCommandBody = z.infer +// Request query schema for GET endpoint +export const deviceQueryParams = z.object({ + merchantName: z.string(), + floorName: z.string(), + unitNumber: z.string(), + deviceName: z.string(), + roomName: z.string(), + deviceType: z.string(), + commandType: z.string(), + towerNumber: z.string(), + timestamp: z.string().datetime(), // ISO8601 + nonce: z.string(), + signature: z.string(), +}) +export type DeviceQueryParams = z.infer + // Response schemas export const deviceCommandResponseSchema = { 200: z.object({ @@ -45,6 +61,50 @@ export const deviceCommandResponseSchema = { status: z.literal('replay_attack_detected'), message: z.literal('Timestamp or nonce is invalid/has been reused'), }), + 404: z.object({ + status: z.literal('not_found'), + message: z.literal('Device status not found'), + }), + 500: z.object({ + status: z.literal('internal_error'), + message: z.literal('Server error'), + }), +} + +// Query response schema +export const deviceQueryResponseSchema = { + 200: z.object({ + status: z.literal('success'), + message: z.literal('command accepted'), + data: z.object({ + floor: z.string(), + unit: z.string(), + device: z.string(), + room: z.string(), + devType: z.string(), + code: z.string(), + tower: z.string(), + payload: z.string(), + }), + }), + 400: z.object({ + status: z.literal('invalid_request'), + message: z.literal('Invalid request format'), + }), + 401: z.union([ + z.object({ + status: z.literal('invalid_signature'), + message: z.literal('Signature mismatch'), + }), + z.object({ + status: z.literal('invalid_api_key'), + message: z.literal('API Key is not valid'), + }), + ]), + 403: z.object({ + status: z.literal('replay_attack_detected'), + message: z.literal('Timestamp or nonce is invalid/has been reused'), + }), 500: z.object({ status: z.literal('internal_error'), message: z.literal('Server error'), diff --git a/src/modules/user/commands/command.ts b/src/modules/user/commands/command.ts deleted file mode 100644 index 309afbe..0000000 --- a/src/modules/user/commands/command.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { db, table } from '~/db' -import type { NewUser } from '../schema' -import { eq } from 'drizzle-orm' - -export default abstract class Command { - static async insertUser(user: NewUser) { - const newUser = await db.insert(table.users).values(user).returning() - return newUser[0] - } - - static async updateUser(id: number, body: NewUser) { - const user = await db.update(table.users) - .set(body) - .where(eq(table.users.id, id)) - .returning() - return user[0] - } - - static async deleteUser(id: number) { - const user = await db.update(table.users) - .set({ deletedAt: new Date() }) - .where(eq(table.users.id, id)) - .returning() - - return user[0] - } -} diff --git a/src/modules/user/commands/service.ts b/src/modules/user/commands/service.ts deleted file mode 100644 index f49ac5b..0000000 --- a/src/modules/user/commands/service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { CreateUserSchema, NewUser } from '../schema' -import Command from './command' -import Query from '../queries/query' -import { NotFoundError } from 'elysia' -import { passwordHash } from '~/helpers/utils' -import { FieldValidationError } from '~/helpers/errors' -import { roleEnum } from '~/db/schema/users' - -export default abstract class CommandService { - static async createUser(body: CreateUserSchema) { - if (!body.email && !body.phone) { - throw new FieldValidationError('/', 'Either email or phone number must be provided') - } - - // check email availability - if (body.email) { - const checkEmail = await Query.getUserByEmail(body.email) - if (checkEmail) { - throw new FieldValidationError('/email', 'Email already in use') - } - } - - if (body.password) { - body.password = passwordHash(body.password) - } - - const newUser = { - ...body, - role: body.role as typeof roleEnum.enumValues[number], - status: true, - } - - const createdUser = await Command.insertUser(newUser) - - return createdUser - } - - static async updateUser(id: number, body: CreateUserSchema) { - // check id - const checkId = await Query.getUserById(id) - if (!checkId) { - throw new NotFoundError('User not found') - } - - // check name - const checkName = await Query.getUserByName(body.name) - if (checkName && checkName.id !== id) { - throw new FieldValidationError('/name', 'Name already in use') - } - - // check email - if (body.email) { - const checkEmail = await Query.getUserByEmail(body.email) - if (checkEmail?.id !== id) { - throw new FieldValidationError('/email', 'Email already in use') - } - } - - const newUser = { - ...body, - status: true, - } as NewUser - - return Command.updateUser(id, newUser) - } - - static async deleteUser(id: number) { - // check id - const checkId = await Query.getUserById(id) - if (!checkId) { - throw new NotFoundError('User not found') - } - - return Command.deleteUser(id) - } -} diff --git a/src/modules/user/queries/query.ts b/src/modules/user/queries/query.ts deleted file mode 100644 index c46ea6b..0000000 --- a/src/modules/user/queries/query.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { db } from '~/db' - -export default abstract class Query { - static async listUser() { - const user = await db.query.users.findMany({ - where: (u, { isNull }) => isNull(u.deletedAt), - }) - return user - } - - static async getUserById(id: number) { - const user = await db.query.users.findFirst({ - columns: { - deletedAt: false, - }, - where: (u, { and, eq, isNull }) => and( - eq(u.id, id), - isNull(u.deletedAt), - ), - }) - - return user - } - - static async getUserByEmail(email: string) { - const user = await db.query.users.findFirst({ - columns: { - deletedAt: false, - }, - where: (u, { and, eq, isNull }) => and( - eq(u.email, email), - isNull(u.deletedAt), - ), - }) - - return user - } - - static async getUserByName(name: string) { - const user = await db.query.users.findFirst({ - columns: { - deletedAt: false, - }, - where: (u, { and, eq, isNull }) => and( - eq(u.name, name), - isNull(u.deletedAt), - ), - }) - - return user - } -} diff --git a/src/modules/user/queries/service.ts b/src/modules/user/queries/service.ts deleted file mode 100644 index 79c979b..0000000 --- a/src/modules/user/queries/service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Query from './query' - -export default abstract class QueryService { - static async getUsers() { - const allUsers = await Query.listUser() - return allUsers - } -} diff --git a/src/modules/user/router.ts b/src/modules/user/router.ts deleted file mode 100644 index a8011ab..0000000 --- a/src/modules/user/router.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Elysia, t } from 'elysia' -import { bearerAuthMiddleware } from '~/middlewares' -import { validationErrorSchema } from '~/plugins/defaultHandler' -import CommandService from './commands/service' -import QueryService from './queries/service' -import { createUserBody, createUserResponse, listUserResponse } from './schema' - -export const router = new Elysia({ - name: 'modules.user', - detail: { tags: ['Users'] }, - prefix: '/user/v1/users', -}) - .get('/', async ({ headers, set }) => { - await bearerAuthMiddleware(headers, set) - - const users = await QueryService.getUsers() - return { - message: 'Get users.' as const, - data: users, - } - }, { - response: { - 200: t.Object({ - message: t.Literal('Get users.'), - data: listUserResponse, - }), - 401: t.Object({ - message: t.Literal('Unauthenticated.'), - }), - }, - }) - .post('/', async ({ body, headers, set }) => { - await bearerAuthMiddleware(headers, set) - - const user = await CommandService.createUser(body) - return { - message: 'User created.', - data: user, - } - }, { - body: createUserBody, - response: { - 200: t.Object({ - message: t.Literal('User created.'), - data: createUserResponse, - }), - 401: t.Object({ - message: t.Literal('Unauthenticated.'), - }), - 422: validationErrorSchema, - }, - }) - .put('/:id', async ({ params: { id }, body, headers, set }) => { - await bearerAuthMiddleware(headers, set) - - const user = await CommandService.updateUser(id, body) - return { - message: 'User updated.' as const, - data: user, - } - }, { - body: createUserBody, - response: { - 200: t.Object({ - message: t.Literal('User updated.'), - data: createUserResponse, - }), - }, - }) - .delete('/:id', async ({ params: { id }, headers, set }) => { - await bearerAuthMiddleware(headers, set) - - await CommandService.deleteUser(id) - return { - message: 'User deleted.' as const, - data: { id }, - } - }, { - response: { - 200: t.Object({ - message: t.Literal('User deleted.'), - data: t.Object({ id: t.String() }), - }), - }, - }) - -export default router diff --git a/src/modules/user/schema.ts b/src/modules/user/schema.ts deleted file mode 100644 index 3fd4b21..0000000 --- a/src/modules/user/schema.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod' -import { createSelectSchema, createInsertSchema } from 'drizzle-zod' -import { table as $t } from '~/db' -import { roleEnum } from '~/db/schema/users' - -export const selectUserSchema = createSelectSchema($t.users) -export const $s = selectUserSchema.shape -export const $i = createInsertSchema($t.users).shape - -export const roleSchema = createSelectSchema(roleEnum) - -export const createUserBody = z.object({ - name: $i.name.min(3), - email: $i.email, - phone: z.string().optional(), - password: z.string().min(8), - role: roleSchema.optional(), // will validate in service - partnerId: z.string().optional(), -}) -export type CreateUserSchema = z.infer - -export const createUserResponse = selectUserSchema.pick({ - id: true, - name: true, - email: true, - phone: true, - role: true, - status: true, - createdAt: true, -}) -export const listUserResponse = z.array(createUserResponse) - -export type User = typeof $t.users.$inferSelect -export type NewUser = typeof $t.users.$inferInsert