Compare commits

..

38 Commits

Author SHA1 Message Date
Admin acc2058bad fix element scrap grays 2025-10-27 09:28:17 +07:00
Admin 85f252cac7 fix timeout wait to verify code 2025-08-25 09:24:29 +07:00
Admin aeebe9a43d fix grays bid 2025-08-22 08:27:10 +07:00
Admin cd0de7331b add api get scrape items 2025-07-21 14:38:21 +07:00
Admin 05d87c367e add api get scrape items 2025-07-21 14:07:15 +07:00
Admin 69610cbbb5 add api get scrape items 2025-07-21 13:57:22 +07:00
Admin 1ac558fe75 change type entity scrap-item 2025-07-19 09:54:43 +07:00
Admin 024b4cd8c8 fix bug allbids histories 2025-07-14 08:12:06 +07:00
Admin 3a1a63bf2a fixd allbids bidding 2025-07-08 15:09:09 +07:00
Admin 41e174f545 update all bid 2025-07-02 15:55:53 +07:00
Admin 52167a360b fix: client update set win when is not rechard close time 2025-06-23 08:46:21 +07:00
Admin 5af2f6e548 update view create 2025-06-20 09:46:45 +07:00
Admin 3e8e7ae7d7 update sanbox mode for product 2025-06-19 16:29:22 +07:00
Admin 41a3baddd8 fix task and mail 2025-06-07 11:24:34 +07:00
Admin 5fea257b68 update view mail report 2025-06-03 10:53:33 +07:00
Admin 58d4809548 update scrapte all bid 2025-05-27 17:00:58 +07:00
Admin d9ff36297a update gray scraping 2025-05-26 09:37:23 +07:00
Admin 7b2f5ecdec update mail from 2025-05-26 09:02:58 +07:00
Admin aac8861eb5 fix build admin 2025-05-23 16:00:24 +07:00
Admin aea4169a50 update template mail 2025-05-23 15:52:58 +07:00
Admin 43dcfc78bb upload future report bid product 2025-05-22 11:11:17 +07:00
Admin 5f1c7c793c upload future report bid product 2025-05-22 11:07:18 +07:00
Admin 4f9edf80d0 scrape 2025-05-22 10:38:30 +07:00
Admin 168d458009 upload future report bid product 2025-05-20 16:48:37 +07:00
Admin 00cc23a46d upload policy file 2025-05-20 09:12:14 +07:00
Admin 16ddb88095 upload policy 2025-05-20 08:57:17 +07:00
Admin f66c9d6c26 upload policy file 2025-05-20 08:44:46 +07:00
Admin cb3bfef759 move extension 2025-05-20 08:03:36 +07:00
Admin d929366810 update view admin 2025-05-20 08:02:14 +07:00
Admin 42f0a881f4 update extension, grays flow, reset tool task 2025-05-16 09:56:39 +07:00
Admin 4d2c39c053 fix spam message 2025-05-14 10:03:53 +07:00
Admin fe4d22f361 fix grays 2025-05-13 13:29:50 +07:00
zelda 44c3934daa Merge pull request 'update view admin' (#38) from zelda.by-pass-langtons-prev-code into main
Reviewed-on: #38
2025-05-12 19:01:49 +10:00
zelda 7f4f7569e5 Merge pull request 'update view admin' (#35) from zelda.by-pass-langtons-prev-code into main
Reviewed-on: #35
2025-05-12 18:16:20 +10:00
zelda c33aab0d5e Merge pull request 'update config future' (#32) from zelda.by-pass-langtons-prev-code into main
Reviewed-on: #32
2025-05-12 17:25:40 +10:00
zelda c19400ef66 Merge pull request 'update time tracking for api bid' (#29) from zelda.by-pass-langtons-prev-code into main
Reviewed-on: #29
2025-05-12 16:51:03 +10:00
zelda 384f6d8b99 Merge pull request 'update clearlazy tab' (#26) from zelda.by-pass-langtons-prev-code into main
Reviewed-on: #26
2025-05-10 13:03:43 +10:00
zelda 838e025274 Merge pull request 'update clear lazy tab' (#23) from zelda.by-pass-langtons-prev-code into main
Reviewed-on: #23
2025-05-10 12:04:14 +10:00
135 changed files with 13082 additions and 1557 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="public/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Bid System</title>
</head>
<body>
<div id="root"></div>

View File

@ -29,6 +29,8 @@
"react": "^19.0.0",
"react-cropper": "^2.3.3",
"react-dom": "^19.0.0",
"react-json-view": "^1.21.3",
"react-player": "^2.16.0",
"react-router": "^6.29.0",
"react-router-dom": "^6.29.0",
"react-toastify": "^11.0.3",
@ -2106,6 +2108,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -2133,6 +2141,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/base16": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
"integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==",
"license": "MIT"
},
"node_modules/bcp-47-match": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz",
@ -2393,6 +2407,15 @@
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2675,6 +2698,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -3138,6 +3170,36 @@
"reusify": "^1.0.4"
}
},
"node_modules/fbemitter": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz",
"integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==",
"license": "BSD-3-Clause",
"dependencies": {
"fbjs": "^3.0.0"
}
},
"node_modules/fbjs": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^3.1.5",
"fbjs-css-vars": "^1.0.0",
"loose-envify": "^1.0.0",
"object-assign": "^4.1.0",
"promise": "^7.1.1",
"setimmediate": "^1.0.5",
"ua-parser-js": "^1.0.35"
}
},
"node_modules/fbjs-css-vars": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -4077,12 +4139,30 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/load-script": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
"integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.curry": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
"integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==",
"license": "MIT"
},
"node_modules/lodash.flow": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
"integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4413,6 +4493,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5099,6 +5185,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -5409,6 +5515,15 @@
"postcss": "^8.2.1"
}
},
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"license": "MIT",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -5436,6 +5551,12 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pure-color": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz",
"integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -5466,6 +5587,18 @@
"node": ">=0.10.0"
}
},
"node_modules/react-base16-styling": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz",
"integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==",
"license": "MIT",
"dependencies": {
"base16": "^1.0.0",
"lodash.curry": "^4.0.1",
"lodash.flow": "^3.3.0",
"pure-color": "^1.2.0"
}
},
"node_modules/react-cropper": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/react-cropper/-/react-cropper-2.3.3.tgz",
@ -5505,12 +5638,53 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-json-view": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz",
"integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==",
"license": "MIT",
"dependencies": {
"flux": "^4.0.1",
"react-base16-styling": "^0.6.0",
"react-lifecycles-compat": "^3.0.4",
"react-textarea-autosize": "^8.3.2"
},
"peerDependencies": {
"react": "^17.0.0 || ^16.3.0 || ^15.5.4",
"react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4"
}
},
"node_modules/react-json-view/node_modules/flux": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz",
"integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==",
"license": "BSD-3-Clause",
"dependencies": {
"fbemitter": "^3.0.0",
"fbjs": "^3.0.1"
},
"peerDependencies": {
"react": "^15.0.2 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz",
@ -5547,6 +5721,22 @@
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-player": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz",
"integrity": "sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.0.0",
"load-script": "^1.0.0",
"memoize-one": "^5.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.0.1"
},
"peerDependencies": {
"react": ">=16.6.0"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@ -6139,6 +6329,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@ -6311,6 +6507,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@ -6386,6 +6588,32 @@
"typescript": ">=4.8.4 <5.8.0"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.40",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz",
"integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@ -7507,6 +7735,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",

View File

@ -31,6 +31,8 @@
"react": "^19.0.0",
"react-cropper": "^2.3.3",
"react-dom": "^19.0.0",
"react-json-view": "^1.21.3",
"react-player": "^2.16.0",
"react-router": "^6.29.0",
"react-router-dom": "^6.29.0",
"react-toastify": "^11.0.3",

View File

@ -1,25 +1,27 @@
import { generateNestParams, handleError, handleSuccess } from '.';
import axios from '../lib/axios';
import { IBid, IWebBid } from '../system/type';
import { removeFalsyValues } from '../utils';
import { generateNestParams, handleError, handleSuccess } from ".";
import axios from "../lib/axios";
import { IBid, IMetadata, IWebBid } from "../system/type";
import { removeFalsyValues } from "../utils";
export const getBids = async (params: Record<string, string | number>) => {
return await axios({
url: 'bids',
url: "bids",
params: generateNestParams(params),
withCredentials: true,
method: 'GET',
method: "GET",
});
};
export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => {
export const createBid = async (
bid: Omit<IBid, "id" | "created_at" | "updated_at" | "is_system_account">
) => {
const newData = removeFalsyValues(bid);
try {
const { data } = await axios({
url: 'bids',
url: "bids",
withCredentials: true,
method: 'POST',
method: "POST",
data: newData,
});
@ -32,14 +34,16 @@ export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_a
};
export const updateBid = async (bid: Partial<IBid>) => {
const { plus_price, max_price, quantity } = removeFalsyValues(bid, ['plus_price']);
const { plus_price, max_price, quantity, metadata } = removeFalsyValues(bid, [
"plus_price",
]);
try {
const { data } = await axios({
url: 'bids/' + bid.id,
url: "bids/" + bid.id,
withCredentials: true,
method: 'PUT',
data: { plus_price, max_price, quantity },
method: "PUT",
data: { plus_price, max_price, quantity, metadata },
});
handleSuccess(data);
@ -53,9 +57,9 @@ export const updateBid = async (bid: Partial<IBid>) => {
export const toggleBid = async (bid: Partial<IBid>) => {
try {
const { data } = await axios({
url: 'bids/toggle/' + bid.id,
url: "bids/toggle/" + bid.id,
withCredentials: true,
method: 'POST',
method: "POST",
});
handleSuccess(data);
@ -69,9 +73,9 @@ export const toggleBid = async (bid: Partial<IBid>) => {
export const deleteBid = async (bid: IBid) => {
try {
const { data } = await axios({
url: 'bids/' + bid.id,
url: "bids/" + bid.id,
withCredentials: true,
method: 'DELETE',
method: "DELETE",
});
handleSuccess(data);
@ -89,9 +93,9 @@ export const deletesBid = async (bids: IBid[]) => {
}, [] as number[]);
try {
const { data } = await axios({
url: 'bids/deletes',
url: "bids/deletes",
withCredentials: true,
method: 'POST',
method: "POST",
data: {
ids,
},
@ -105,16 +109,79 @@ export const deletesBid = async (bids: IBid[]) => {
}
};
export const getImagesWorking = async (values: (IBid | IWebBid) & { type: string }) => {
export const getImagesWorking = async (
values: (IBid | IWebBid) & { type: string }
) => {
try {
const { data } = await axios({
url: `bids/images-working/${values.type.toLocaleLowerCase().replace('_', '-')}/${values.id}`,
url: `bids/images-working/${values.type
.toLocaleLowerCase()
.replace("_", "-")}/${values.id}`,
withCredentials: true,
method: 'GET',
method: "GET",
});
return data;
} catch (error) {
console.log('%csrc/apis/bid.ts:118 error', 'color: #007acc;', error);
console.log("%csrc/apis/bid.ts:118 error", "color: #007acc;", error);
}
};
export const getRecords = async (values: IBid) => {
try {
const { data } = await axios({
url: `bids/records/${values.id}`,
withCredentials: true,
method: "GET",
});
return data;
} catch (error) {
console.log("%csrc/apis/bid.ts:118 error", "color: #007acc;", error);
}
};
export const deleteRecord = async (name: string) => {
try {
const { data } = await axios({
url: `bids/record/${name}`,
withCredentials: true,
method: "DELETE",
});
return data;
} catch (error) {
console.log("%csrc/apis/bid.ts:154 error", "color: #007acc;", error);
}
};
export const deleteMetadata = async (id: IMetadata["id"]) => {
try {
const { data } = await axios({
url: `bid-metadatas/${id}`,
withCredentials: true,
method: "DELETE",
});
return data;
} catch (error) {
console.log("%csrc/apis/bid.ts:154 error", "color: #007acc;", error);
}
};
export const updateMetadata = async (id: IMetadata["id"], value: string) => {
try {
const { data } = await axios({
url: `bid-metadatas/${id}`,
withCredentials: true,
method: "PUT",
data: {
value,
},
});
return data;
} catch (error) {
console.log("%csrc/apis/bid.ts:154 error", "color: #007acc;", error);
}
};

View File

@ -0,0 +1,33 @@
import { handleError } from ".";
import axios from "../lib/axios";
import { IConfig } from "../system/type";
import { removeFalsyValues } from "../utils";
export const CONFIG_KEYS = {
MAIL_SCRAP_REPORT: "MAIL_SCRAP_REPORT",
};
export const getConfig = async (key: keyof typeof CONFIG_KEYS) => {
return await axios({
url: "configs/" + key,
withCredentials: true,
method: "GET",
});
};
export const upsertConfig = async (data: IConfig) => {
const { key_name, value, type } = removeFalsyValues(data, ["value"]);
try {
const { data } = await axios({
url: "configs/upsert",
withCredentials: true,
method: "POST",
data: { key_name, value: value || null, type },
});
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -0,0 +1,50 @@
import { handleError, handleSuccess } from ".";
import axios from "../lib/axios";
import { IScrapConfig, IWebBid } from "../system/type";
import { removeFalsyValues } from "../utils";
export const createScrapConfig = async (
bid: Omit<
IScrapConfig,
"id" | "created_at" | "updated_at" | "scrap_items"
> & { web_id: IWebBid["id"] }
) => {
const newData = removeFalsyValues(bid);
try {
const { data } = await axios({
url: "scrap-configs",
withCredentials: true,
method: "POST",
data: {
...newData,
enable: newData.enable === "1",
},
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const updateScrapConfig = async (scrapConfig: Partial<IScrapConfig>) => {
const { search_url, keywords, id, enable } = removeFalsyValues(scrapConfig);
try {
const { data } = await axios({
url: "scrap-configs/" + id,
withCredentials: true,
method: "PUT",
data: { search_url, keywords, enable: enable === "1" },
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -1,25 +1,65 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, LoadingOverlay, Modal, ModalProps, NumberInput, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { createBid, updateBid } from '../../apis/bid';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IBid } from '../../system/type';
import {
Button,
LoadingOverlay,
Modal,
ModalProps,
NumberInput,
Select,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import _ from "lodash";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { createBid, updateBid } from "../../apis/bid";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IBid, IMetadata } from "../../system/type";
import { formatTimeFromMinutes } from "../../utils";
export interface IBidModelProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
const schema = {
url: z.string({ message: 'Url is required' }).url('Invalid url format'),
max_price: z.number({ message: 'Max price is required' }).min(1, 'Max price must be at least 1'),
plus_price: z.number().min(0, 'Plus price must be at least 1').optional(),
quantity: z.number().min(1, 'Quantity must be at least 1').optional(),
url: z.string({ message: "Url is required" }).url("Invalid url format"),
max_price: z
.number({ message: "Max price is required" })
.min(1, "Max price must be at least 1"),
plus_price: z.number().min(0, "Plus price must be at least 1").optional(),
quantity: z.number().min(1, "Quantity must be at least 1").optional(),
arrival_offset_seconds_live: z
.number({ message: "Arrival offset seconds is required" })
.refine((val) => val >= 60, {
message: "Arrival offset seconds must be at least 60 seconds (1 minute)",
})
.optional(),
early_tracking_seconds_live: z
.number({ message: "Early login seconds is required" })
.refine((val) => val >= 600, {
message: "Early login seconds must be at least 600 seconds (10 minute)",
})
.optional(),
arrival_offset_seconds_sandbox: z
.number({ message: "Arrival offset seconds is required" })
.refine((val) => val >= 60, {
message: "Arrival offset seconds must be at least 60 seconds (1 minute)",
})
.optional(),
early_tracking_seconds_sandbox: z
.number({ message: "Early login seconds is required" })
.refine((val) => val >= 600, {
message: "Early login seconds must be at least 600 seconds (10 minute)",
})
.optional(),
};
export default function BidModal({ data, onUpdated, ...props }: IBidModelProps) {
export default function BidModal({
data,
onUpdated,
...props
}: IBidModelProps) {
const form = useForm({
validate: zodResolver(z.object(schema)),
});
@ -33,11 +73,16 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
title: "Update ?",
message: `This product will be update`,
handleOk: async () => {
setLoading(true);
const result = await updateBid(values);
const metadata = valuesToMetadata(
values as IBid & Record<string, any>
);
const result = await updateBid({ ...values, metadata });
setLoading(false);
if (!result) return;
@ -49,15 +94,23 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
}
},
okButton: {
color: 'blue',
value: 'Update',
color: "blue",
value: "Update",
},
});
} else {
const { url, max_price, plus_price } = values;
setLoading(true);
const result = await createBid({ url, max_price, plus_price } as IBid);
const metadata = valuesToMetadata(values as IBid & Record<string, any>);
const result = await createBid({
url,
max_price,
plus_price,
metadata,
} as IBid);
setLoading(false);
@ -71,11 +124,101 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
}
};
const mappingValues = (ignore: string[] = []) => {
if (!data) return {};
let values: IBid & Record<string, any> = data;
const followKey = ["arrival_offset_seconds", "early_tracking_seconds"];
if (
data.metadata.length &&
data.metadata.some((item) => item.key_name === "mode_key")
) {
data.metadata.reduce((prev, cur) => {
if (ignore.includes(cur.key_name)) {
prev[cur.key_name as string] = form.values[cur.key_name];
return prev;
}
if (cur.key_name === "mode_key") {
prev[cur.key_name as string] = cur.value;
return prev;
}
prev[cur.key_name as string] = cur.value;
return prev;
}, values);
} else {
const metadata = Object.assign(
{ mode_key: "live" },
...followKey.map((item) => ({
[`${item}_live`]: data.web_bid[item as keyof typeof data.web_bid],
[`${item}_sandbox`]: data.web_bid[item as keyof typeof data.web_bid],
}))
);
values = { ...values, ...metadata };
}
return values;
};
const valuesToMetadata = (values: IBid & Record<string, any>) => {
const keys = [
"mode_key",
"arrival_offset_seconds_live",
"arrival_offset_seconds_sandbox",
"early_tracking_seconds_live",
"early_tracking_seconds_sandbox",
];
if (!values?.metadata) {
const mode_key = values.mode_key;
if (!mode_key) return [];
const newValues = Object.entries(values)
.map(([key, value]) => {
if (keys.includes(key)) {
return {
key_name: key,
value,
} as IMetadata;
}
return null;
})
.filter((i) => i !== null);
return newValues;
}
if (values.metadata.length <= 0) {
return keys.map((item) => {
return {
key_name: item,
value: values[item],
} as IMetadata;
});
}
return values.metadata.map((item) => {
return {
...item,
value: values[item.key_name],
};
});
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
const values = mappingValues();
form.setValues(values);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -88,30 +231,127 @@ export default function BidModal({ data, onUpdated, ...props }: IBidModelProps)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
useEffect(() => {
if (!data && !form.values.mode_key) {
form.setValues({ mode_key: "live" });
} else {
const values = mappingValues(["mode_key"]);
form.setValues(values);
}
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.values["mode_key"]]);
return (
<Modal
className="relative"
classNames={{
header: '!flex !item-center !justify-center w-full',
header: "!flex !item-center !justify-center w-full",
}}
{...props}
size={'xl'}
size={"xl"}
title={<span className="text-xl font-bold">Bid</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
{data && data.name && <TextInput className="col-span-2" readOnly={!!data} size="sm" label="Name" value={data.name} />}
<TextInput className="col-span-2" readOnly={!!data} size="sm" label="Url" {...form.getInputProps('url')} />
<NumberInput className="col-span-2" size="sm" label="Max price" {...form.getInputProps('max_price')} />
<NumberInput size="sm" label="Plus price" {...form.getInputProps('plus_price')} />
<NumberInput size="sm" label="Quantity" {...form.getInputProps('quantity')} />
<form
onSubmit={form.onSubmit(handleSubmit)}
className="grid grid-cols-2 gap-2.5"
>
<Select
className="col-span-2"
label="Mode"
data={[
{ label: "Live", value: "live" },
{ label: "Sandbox", value: "sandbox" },
]}
defaultValue="live"
checkIconPosition="right"
allowDeselect={false}
{...form.getInputProps("mode_key")}
/>
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{data ? 'Update' : 'Create'}
{data && data.name && (
<TextInput
className="col-span-2"
readOnly={!!data}
size="sm"
label="Name"
value={data.name}
/>
)}
<TextInput
className="col-span-2"
readOnly={!!data}
size="sm"
label="Url"
{...form.getInputProps("url")}
/>
<NumberInput
className="col-span-2"
size="sm"
label="Max price"
{...form.getInputProps("max_price")}
/>
<NumberInput
size="sm"
label="Plus price"
{...form.getInputProps("plus_price")}
/>
<NumberInput
size="sm"
label="Quantity"
{...form.getInputProps("quantity")}
/>
<NumberInput
description="Note: that only integer minutes are accepted."
className="col-span-1"
size="sm"
label={`Arrival offset seconds (${formatTimeFromMinutes(
form.getValues()[
`arrival_offset_seconds_${form.getValues()["mode_key"]}`
] / 60
)})`}
placeholder="msg: 300"
{...form.getInputProps(
`arrival_offset_seconds_${form.getValues()["mode_key"]}`
)}
/>
<NumberInput
description="Note: that only integer minutes are accepted."
className="col-span-1"
size="sm"
label={`Early tracking seconds (${formatTimeFromMinutes(
form.getValues()[
`early_tracking_seconds_${form.getValues()["mode_key"]}`
] / 60
)})`}
placeholder="msg: 600"
{...form.getInputProps(
`early_tracking_seconds_${form.getValues()["mode_key"]}`
)}
/>
<Button
// disabled={_.isEqual(form.values, prevData.current)}
className="col-span-2"
type="submit"
fullWidth
size="sm"
mt="md"
>
{data ? "Update" : "Create"}
</Button>
</form>
<LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
);
}

View File

@ -17,11 +17,13 @@ export default function DeleteRowAction({
setConfirm({
handleOk: async () => {
const result = await deletesBid(chooses);
console.log({ result });
if (!result) return;
onDeleted?.();
},
title: 'Delete',
message: `This action will remove ${chooses.length} products.`
title: "Delete",
message: `This action will remove ${chooses.length} products.`,
});
};

View File

@ -1,4 +1,4 @@
export { default as ShowHistoriesModal } from './show-histories-modal';
export { default as ShowHistoriesBidGraysApiModal } from './show-histories-bid-grays-api-modal';
export { default as ShowHistoriesBidPicklesApiModal } from './show-histories-bid-pickles-api-modal';
export { default as BidModal } from './bid-modal';
export { default as ShowHistoriesModal } from "./show-histories-modal";
export { default as BidModal } from "./bid-modal";
export { default as RecordModal } from "./record-modal";
export { default as ResponseDemoModal } from "./response-demo-modal";

View File

@ -0,0 +1,168 @@
import {
ActionIcon,
Box,
Card,
Group,
Menu,
Modal,
ModalProps,
ScrollArea,
Text,
Title,
} from "@mantine/core";
import {
IconDots,
IconPlayerRecordFilled,
IconTrash,
IconVideoFilled,
} from "@tabler/icons-react";
import moment from "moment";
import { useEffect, useState } from "react";
import ReactPlayer from "react-player";
import { deleteRecord, getRecords } from "../../apis/bid";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IBid } from "../../system/type";
export interface IRecordModalProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
interface IRecord {
bid_id: number;
name: string;
original_name: string;
timestamp: number;
}
export default function RecordModal({ data, ...props }: IRecordModalProps) {
const [records, setRecords] = useState<IRecord[]>([]);
const [selected, setSelected] = useState<IRecord | null>(null);
const { setConfirm } = useConfirmStore();
const fetch = async () => {
if (!data) return;
const response = await getRecords(data);
if (response?.data) {
setRecords(response.data);
setSelected(response.data[0]);
}
};
const handleDelete = async (item: IRecord) => {
setConfirm({
handleOk: async () => {
const result = await deleteRecord(item.original_name);
if (!result?.data) return;
fetch();
},
});
};
useEffect(() => {
fetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
return (
<Modal
classNames={{
header: "!flex !justify-center w-full",
}}
{...props}
size={"xl"}
title={<Title order={3}>Video Records</Title>}
centered
>
<Box className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Sidebar - Record list */}
{records.length > 0 && (
<ScrollArea className="md:col-span-1 h-[400px] pr-2">
{records.map((item) => (
<Card
key={item.original_name}
withBorder
shadow="xs"
radius="md"
className={`mb-3 cursor-pointer transition-all ${
selected?.original_name === item.original_name
? "border-blue-500 bg-blue-50"
: "hover:bg-gray-50"
}`}
onClick={() => setSelected(item)}
>
<Card.Section withBorder inheritPadding py="xs">
<Group justify="space-between">
<Group>
<IconVideoFilled />
{selected &&
selected.original_name === item.original_name && (
<IconPlayerRecordFilled
size={14}
color="red"
className="animate-pulse"
/>
)}
</Group>
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleDelete(item)}
leftSection={<IconTrash size={14} />}
color="red"
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Card.Section>
<Text mt={"xs"} size="sm" fw={500}>
{item.name}
</Text>
<Text mt="xs" c="dimmed" size="xs">
{moment(Number(item.timestamp)).fromNow()}
</Text>
</Card>
))}
</ScrollArea>
)}
{/* Player - Main content */}
{records.length > 0 && (
<Box className="md:col-span-2">
{selected && (
<ReactPlayer
url={`${import.meta.env.VITE_BASE_URL}bids/record/${
selected.original_name
}`}
controls
width="100%"
height="auto"
/>
)}
</Box>
)}
{records.length <= 0 && (
<Box className="col-span-3">
<Text className="text-center" c="dimmed">
No video
</Text>
</Box>
)}
</Box>
</Modal>
);
}

View File

@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
ActionIcon,
Box,
Card,
Group,
Menu,
Modal,
ModalProps,
ScrollArea,
Text,
Title,
} from "@mantine/core";
import {
IconCodeCircle,
IconDots,
IconPlayerRecordFilled,
IconTrash,
} from "@tabler/icons-react";
import moment from "moment";
import { useEffect, useRef, useState } from "react";
import ReactJson from "react-json-view";
import { updateMetadata } from "../../apis/bid";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IBid, IMetadata } from "../../system/type";
import { getResponseDemo } from "../../utils";
export interface IResponseDemoProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
export default function ResponseDemoModal({
data,
...props
}: IResponseDemoProps) {
const { setConfirm } = useConfirmStore();
const [selected, setSelected] = useState<Record<string, any> | null>(null);
const [responseDemo, setResponseDemo] = useState<null | IMetadata>(null);
const dataRef = useRef<IBid | null>(data);
// const responseDemo = useMemo(() => {
// if (!data) return null;
// const values = getResponseDemo(data);
// if (Array.isArray(values?.value) && values?.value[0]) {
// setSelected(values?.value[0]);
// }
// return values;
// }, [data]);
useEffect(() => {
if (!data) return;
const value = getResponseDemo(data);
if (Array.isArray(value?.value) && value?.value[0]) {
setSelected(value?.value[0]);
}
setResponseDemo(value);
dataRef.current = data;
}, [data]);
const handleDelete = async () => {
if (!responseDemo || !selected) return;
console.log(selected);
setConfirm({
handleOk: async () => {
const prevData = responseDemo.value as Record<string, any>[];
const newData = prevData.filter(
(item) => item?.uuid !== selected?.uuid
);
const result = await updateMetadata(
responseDemo.id,
JSON.stringify(newData)
);
if (!result?.data) return;
props.onUpdated?.();
setResponseDemo(result.data);
if (result?.data?.value[0]) {
setSelected(result?.data?.value[0]);
} else {
setSelected(null);
}
// props.onClose();
},
});
};
return (
<Modal
classNames={{
header: "!flex !justify-center w-full",
}}
{...props}
size={"xl"}
title={<Title order={3}>Response Demo</Title>}
centered
>
<Box className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Sidebar - Record list */}
{responseDemo && responseDemo?.value.length > 0 && (
<ScrollArea className="md:col-span-1 h-[600px] pr-2">
{Array.isArray(responseDemo.value) &&
responseDemo.value.map((item) => {
return (
<Card
onClick={() => setSelected(item)}
key={item.uuid}
withBorder
shadow="xs"
radius="md"
className={`mb-3 cursor-pointer transition-all ${"border-blue-500 bg-blue-50"}`}
>
<Card.Section withBorder inheritPadding py="xs">
<Group justify="space-between">
<Group>
<IconCodeCircle />
{selected && selected?.uuid === item?.uuid && (
<IconPlayerRecordFilled
size={14}
color="red"
className="animate-pulse"
/>
)}
</Group>
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleDelete}
leftSection={<IconTrash size={14} />}
color="red"
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Card.Section>
<Text mt={"xs"} size="sm" fw={500}>
{dataRef.current?.name}
</Text>
<Text mt="xs" c="dimmed" size="xs">
{item?.timestamp &&
moment(Number(item?.timestamp)).fromNow()}
</Text>
</Card>
);
})}
</ScrollArea>
)}
{/* Player - Main content */}
{responseDemo?.value && selected && (
<ScrollArea h={600} className="md:col-span-2">
<ReactJson theme={"bespin"} src={selected || {}} />
</ScrollArea>
)}
{(!responseDemo ||
(Array.isArray(responseDemo.value) &&
responseDemo.value.length <= 0)) && (
<Box className="col-span-3">
<Text className="text-center" c="dimmed">
No response
</Text>
</Box>
)}
</Box>
</Modal>
);
}

View File

@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Table from "../../../lib/table/table";
import { IColumn } from "../../../lib/table/type";
import { formatTime } from "../../../utils";
export interface IGraysHistoriesViewProps {
histories: Record<string, string>[];
}
export default function AllbidsHistoriesView({
histories,
}: IGraysHistoriesViewProps) {
type BidHistoryEntry = {
row_id: number;
date: string; // ISO datetime string
amount: number; // Số tiền đặt giá
proxyamount: number; // Giá proxy tối đa
bidQty: number; // Số lượng
flashBuy: boolean; // Mua ngay
proxyBid: boolean; // Có phải đấu giá tự động
instantBid: boolean; // Đặt giá ngay
userName: string; // Tên người dùng
bidBidderID: number; // ID người đặt giá
$$hashKey?: string; // Khóa nội bộ Angular (không cần thiết, có thể bỏ hoặc để optional)
};
const columns: IColumn<BidHistoryEntry>[] = [
{
title: "Username",
key: "userName",
},
{
title: "Amount",
key: "amount",
},
{
title: "Proxy amount",
key: "proxyamount",
},
{
title: "Bid Qty",
key: "bidQty",
},
{
title: "Bid at",
key: "date",
renderRow(row) {
return <span>{formatTime(row.date)}</span>;
},
},
];
return (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
styleDefaultHead={{
justifyContent: "flex-start",
width: "fit-content",
}}
showFilter={false}
showActions={false}
showChooses={false}
columns={columns}
rowKey="row_id"
rows={histories as unknown as BidHistoryEntry[]}
/>
);
}

View File

@ -0,0 +1,79 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Table from "../../../lib/table/table";
import { IColumn } from "../../../lib/table/type";
import { extractNumber, formatTime } from "../../../utils";
export interface IGraysHistoriesViewProps {
histories: Record<string, string>[];
}
export default function GraysHistoriesView({
histories,
}: IGraysHistoriesViewProps) {
type BidHistoryEntry = {
row_id: number;
Price: string;
Quantity: number;
WinningQuantity: number;
UserShortAddress: string;
UserInitials: string;
OriginalDate: string;
};
const columns: IColumn<BidHistoryEntry>[] = [
{
title: "Bidding Details",
key: "UserInitials",
renderRow(row) {
return (
<span>{`${row["UserInitials"]} - ${row["UserShortAddress"]}`}</span>
);
},
},
{
title: "Bid Time",
key: "OriginalDate",
renderRow(row) {
return (
<span>
{formatTime(
new Date(extractNumber(row["OriginalDate"]) || 0).toUTCString(),
"HH:mm:ss DD/MM/YYYY"
)}
</span>
);
},
},
{
title: "Bid Price",
key: "Price",
},
{
title: "Bid Qty",
key: "Quantity",
},
{
title: "Win Qty",
key: "WinningQuantity",
},
];
return (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
styleDefaultHead={{
justifyContent: "flex-start",
width: "fit-content",
}}
showFilter={false}
showActions={false}
showChooses={false}
columns={columns}
rowKey="row_id"
rows={histories as unknown as BidHistoryEntry[]}
/>
);
}

View File

@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Table from "../../../lib/table/table";
import { IColumn } from "../../../lib/table/type";
import { formatTime } from "../../../utils";
export interface IGraysHistoriesViewProps {
histories: Record<string, string>[];
}
export default function PicklesHistoriesView({
histories,
}: IGraysHistoriesViewProps) {
// const rows = useMemo(() => {
// return histories.map((element, index) => (
// <Table.Tr key={index}>
// <Table.Td>{element["bidderAnonName"]}</Table.Td>
// <Table.Td>{element["actualBid"]}</Table.Td>
// <Table.Td>
// {formatTime(
// new Date(element["bidTimeInMilliSeconds"]).toUTCString(),
// "HH:mm:ss DD/MM/YYYY"
// )}
// </Table.Td>
// </Table.Tr>
// ));
// }, [histories]);
type BidHistoryEntry = {
row_id: number;
bidderAnonName: string;
actualBid: number;
bidTimeInMilliSeconds: number;
};
const columns: IColumn<BidHistoryEntry>[] = [
{
title: "Bidder name",
key: "bidderAnonName",
},
{
title: "Actual bid",
key: "actualBid",
},
{
title: "Time",
key: "bidTimeInMilliSeconds",
renderRow(row) {
return (
<span>
{formatTime(
new Date(row["bidTimeInMilliSeconds"]).toUTCString(),
"HH:mm:ss DD/MM/YYYY"
)}
</span>
);
},
},
];
return (
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
styleDefaultHead={{
justifyContent: "flex-start",
width: "fit-content",
}}
showFilter={false}
showActions={false}
showChooses={false}
columns={columns}
rowKey="row_id"
rows={histories as unknown as BidHistoryEntry[]}
/>
);
}

View File

@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LoadingOverlay, Modal, ModalProps } from "@mantine/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getDetailBidHistories } from "../../../apis/bid-histories";
import { IBid } from "../../../system/type";
import GraysHistoriesView from "./grays-histories-view";
import constants from "../../../constant";
import PicklesHistoriesView from "./pickles-histories-view";
import AllbidsHistoriesView from "./allbids-histories-view";
export interface IShowHistoriesApiModalProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
export default function ShowHistoriesApiModal({
data,
onUpdated,
...props
}: IShowHistoriesApiModalProps) {
const [histories, setHistories] = useState<Record<string, string>[]>([]);
const [loading, setLoading] = useState(false);
const handleCallApi = useCallback(async () => {
if (!data?.lot_id) {
setHistories([]);
return;
}
setLoading(true);
const response = await getDetailBidHistories(data?.lot_id);
setLoading(false);
if (response.data && response.data) {
const values = (response.data as Record<string, string>[]).map(
(item, index) => {
return {
...item,
row_id: String(index),
};
}
);
setHistories(values);
}
}, [data]);
useEffect(() => {
handleCallApi();
}, [handleCallApi]);
const generateView = useMemo(() => {
switch (data?.web_bid.origin_url) {
case constants.grays:
return <GraysHistoriesView histories={histories} />;
case constants.pickles:
return <PicklesHistoriesView histories={histories} />;
case constants.allbids:
return <AllbidsHistoriesView histories={histories} />;
}
}, [data?.web_bid.origin_url, histories]);
return (
<Modal
className="relative"
{...props}
size="xl"
title={<span className="text-xl font-bold">BIDDING HISTORY</span>}
centered
>
{generateView}
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
);
}

View File

@ -1,78 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LoadingOverlay, Modal, ModalProps, Table } from '@mantine/core';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getDetailBidHistories } from '../../apis/bid-histories';
import { IBid } from '../../system/type';
import { extractNumber, formatTime } from '../../utils';
export interface IShowHistoriesBidGraysApiModalProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
export default function ShowHistoriesBidGraysApiModal({ data, onUpdated, ...props }: IShowHistoriesBidGraysApiModalProps) {
const [histories, setHistories] = useState<Record<string, string>[]>([]);
const [loading, setLoading] = useState(false);
const rows = useMemo(() => {
return histories.map((element, index) => (
<Table.Tr key={index}>
<Table.Td>{`${element['UserInitials']} - ${element['UserShortAddress']}`}</Table.Td>
<Table.Td>{formatTime(new Date(extractNumber(element['OriginalDate']) || 0).toUTCString(), 'HH:mm:ss DD/MM/YYYY')}</Table.Td>
<Table.Td>{`AU $${element['Price']}`}</Table.Td>
<Table.Td>{`${element['Quantity']}`}</Table.Td>
<Table.Td>{`${element['WinningQuantity']}`}</Table.Td>
</Table.Tr>
));
}, [histories]);
const handleCallApi = useCallback(async () => {
if (!data?.lot_id) {
setHistories([]);
return;
}
setLoading(true);
const response = await getDetailBidHistories(data?.lot_id);
setLoading(false);
if (response.data && response.data) {
setHistories(response.data);
}
}, [data]);
useEffect(() => {
handleCallApi();
}, [handleCallApi]);
return (
<Modal className="relative" {...props} size="xl" title={<span className="text-xl font-bold">BIDDING HISTORY</span>} centered>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Bidding Details</Table.Th>
<Table.Th>Bid Time</Table.Th>
<Table.Th>Bid Price</Table.Th>
<Table.Th>Bid Qty</Table.Th>
<Table.Th>Win Qty</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{histories.length <= 0 ? (
<Table.Tr>
<Table.Td colSpan={5} className="text-center">
None
</Table.Td>
</Table.Tr>
) : (
rows
)}
</Table.Tbody>
</Table>
<LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
</Modal>
);
}

View File

@ -0,0 +1,161 @@
import {
ActionIcon,
Box,
Card,
Group,
LoadingOverlay,
Text,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { IconAt, IconMinus, IconPlus } from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { getConfig, upsertConfig } from "../../apis/config";
import { IConfig } from "../../system/type";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { useDisclosure } from "@mantine/hooks";
const schema = z.object({
email: z
.string({ message: "Email is required" })
.email({ message: "Invalid email address" }),
});
const MailInput = ({
initValue,
onDelete,
onAdd,
}: {
initValue?: string;
onDelete?: (data: string) => void;
onAdd?: (data: string) => Promise<void>;
}) => {
const form = useForm({
initialValues: {
email: initValue || "",
},
validate: zodResolver(schema),
});
return (
<form
onSubmit={form.onSubmit(
onAdd
? async (values) => {
await onAdd(values.email);
form.reset();
}
: () => {}
)}
className="flex items-start gap-2 w-full"
>
<TextInput
{...form.getInputProps("email")}
leftSection={<IconAt size={14} />}
placeholder="Enter email"
className="flex-1"
size="xs"
/>
<ActionIcon
onClick={initValue && onDelete ? () => onDelete(initValue) : undefined}
type={!initValue ? "submit" : "button"}
color={initValue ? "red" : "blue"}
variant="light"
>
{initValue ? <IconMinus size={14} /> : <IconPlus size={14} />}
</ActionIcon>
</form>
);
};
export default function MailsConfig() {
const [config, setConfig] = useState<null | IConfig>(null);
const { setConfirm } = useConfirmStore();
const [opened, { open, close }] = useDisclosure(false);
useEffect(() => {
fetchConfig();
}, []);
const mails = useMemo(() => {
if (!config) return [];
return config?.value?.split(", ").length > 0
? config?.value.split(",")
: [];
}, [config]);
const fetchConfig = async () => {
const response = await getConfig("MAIL_SCRAP_REPORT");
if (!response || ![200, 201].includes(response.data?.status_code)) return;
setConfig(response.data.data);
};
const handleDelete = (mail: string) => {
setConfirm({
message: "Are you want to delete: " + mail,
title: "Delete",
handleOk: async () => {
open();
const newMails = mails.filter((item) => item !== mail);
if (!config) return;
const response = await upsertConfig({
...(config as IConfig),
value: newMails.join(", "),
});
if (response) {
fetchConfig();
}
close();
},
});
};
const handleAdd = async (mail: string) => {
const newMails = [...mails, mail.trim()];
open();
const response = await upsertConfig({
...(config as IConfig),
value: newMails.join(", "),
});
if (response) {
fetchConfig();
}
close();
};
return (
<Card withBorder shadow="sm" radius="md" w={400}>
<Card.Section withBorder inheritPadding py="xs">
<Group justify="space-between">
<Text fw={500}>Mails</Text>
</Group>
</Card.Section>
<Card.Section p="md">
<Box className="flex flex-col gap-2">
{mails.length > 0 &&
mails.map((mail) => {
return (
<MailInput
onDelete={handleDelete}
key={mail}
initValue={mail}
/>
);
})}
<MailInput onAdd={handleAdd} />
</Box>
</Card.Section>
<LoadingOverlay visible={opened} />
</Card>
);
}

View File

@ -1,21 +1,37 @@
import { Badge, Box, Button, Image, Text, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
ActionIcon,
Badge,
Box,
Button,
Image,
Text,
Tooltip,
} from "@mantine/core";
import { upperFirst, useDisclosure } from "@mantine/hooks";
import moment from "moment";
import { useEffect, useState } from "react";
import { Socket } from "socket.io-client";
import { getImagesWorking } from "../../apis/bid";
import { getImagesWorking, updateBid } from "../../apis/bid";
import { useStatusToolStore } from "../../lib/zustand/use-status-tool-store";
import { IBid, IWebBid } from "../../system/type";
import {
cn,
extractDomainSmart,
findNearestClosingChild,
getEarlyTrackingSeconds,
getMode,
isTimeReached,
stringToColor,
subtractSeconds,
} from "../../utils";
import ShowImageModal from "./show-image-modal";
import { IconExternalLink, IconImageInPicture } from "@tabler/icons-react";
import {
IconExternalLink,
IconImageInPicture,
IconLivePhoto,
IconTestPipe,
} from "@tabler/icons-react";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
export interface IWorkingPageProps {
data: (IBid | IWebBid) & { type: string };
socket: Socket;
@ -38,6 +54,8 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
const { statusTool } = useStatusToolStore();
const { setConfirm } = useConfirmStore();
function isIBid(obj: IBid | IWebBid): obj is IBid {
return "name" in obj;
}
@ -66,6 +84,33 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
return !statusTool || statusTool !== "online" ? "Unknown" : "logout";
};
const handleToogleMode = async () => {
if (!isIBid(data)) return;
setConfirm({
title: "Confirm changing this product's mode?",
okButton: {
value: "Sure",
color: "blue",
},
handleOk: async () => {
const metadata = data.metadata;
const modeKey = metadata.find((item) => item.key_name === "mode_key");
if (modeKey && modeKey.value) {
if (String(modeKey.value) === "live") {
modeKey.value = "sandbox";
} else {
modeKey.value = "live";
}
}
await updateBid({ ...data, metadata });
},
});
};
useEffect(() => {
const updateImage = ({
type,
@ -118,10 +163,6 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!isIBid(data)) {
console.log(data);
}
return (
<>
<Box
@ -182,7 +223,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
!isTimeReached(
subtractSeconds(
data.close_time,
data.web_bid?.early_tracking_seconds || 0
getEarlyTrackingSeconds(data) || 0
)
) && (
<Tooltip label={"Time to tracking"}>
@ -192,7 +233,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
>{`TT: ${moment(
subtractSeconds(
data.close_time,
data.web_bid?.early_tracking_seconds || 0
getEarlyTrackingSeconds(data) || 0
)
).format("HH:mm:ss DD/MM/YYYY")}`}</Text>
</Tooltip>
@ -221,7 +262,9 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
</Box>
</Box>
<Box className="absolute top-2.5 left-2.5 flex items-center gap-2">
<Box className="absolute top-0 left-0 py-2 px-4 flex items-center gap-2 justify-between w-full">
<Box className="flex justify-between w-full">
<Box className="flex items-center gap-2">
<Badge
color={payloadLoginStatus?.login_status ? "green" : "red"}
size="xs"
@ -242,6 +285,27 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
: extractDomainSmart(data.origin_url)}
</Badge>
</Box>
{isIBid(data) && (
<Tooltip label={upperFirst(getMode(data))}>
<ActionIcon
onClick={handleToogleMode}
color={getMode(data) === "live" ? "teal" : "orange"}
size="xs"
>
{getMode(data) === "live" ? (
<IconLivePhoto size={14} />
) : (
<IconTestPipe size={14} />
)}
</ActionIcon>
</Tooltip>
)}
</Box>
{isIBid(data) && moment(data.close_time).isSame(moment(), "day") && (
<div className="w-[14px] h-[14px] rounded-full bg-green-600 animate-pulse"></div>
)}
</Box>
</Box>
<ShowImageModal

View File

@ -1,24 +1,41 @@
import { Avatar, Button, LoadingOverlay, Menu, Modal, PasswordInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconKey, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { z } from 'zod';
import { changePassword, logout } from '../apis/auth';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import Links from '../system/links';
import {
Avatar,
Button,
LoadingOverlay,
Menu,
Modal,
PasswordInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import {
IconCode,
IconKey,
IconLogout,
IconSettings,
IconUser,
} from "@tabler/icons-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { Link } from "react-router-dom";
import { z } from "zod";
import { changePassword, logout } from "../apis/auth";
import { useConfirmStore } from "../lib/zustand/use-confirm";
import Links from "../system/links";
const schema = z
.object({
currentPassword: z.string().min(6, 'Current password must be at least 6 characters'),
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
currentPassword: z
.string()
.min(6, "Current password must be at least 6 characters"),
newPassword: z
.string()
.min(6, "New password must be at least 6 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
path: ["confirmPassword"],
message: "Passwords do not match",
});
export default function UserMenu() {
@ -31,9 +48,9 @@ export default function UserMenu() {
const navigate = useNavigate();
const form = useForm({
initialValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
validate: zodResolver(schema),
});
@ -45,8 +62,8 @@ export default function UserMenu() {
const handleLogout = async () => {
setConfirm({
title: "Are you wan't to logout?",
message: 'This account will logout !',
okButton: { value: 'Logout' },
message: "This account will logout !",
okButton: { value: "Logout" },
handleOk: async () => {
const data = await logout();
@ -60,8 +77,8 @@ export default function UserMenu() {
const handleChangePassword = async (values: typeof form.values) => {
setConfirm({
title: "Are you wan't to update password",
message: 'This account will change password !',
okButton: { value: 'Sure' },
message: "This account will change password !",
okButton: { value: "Sure" },
handleOk: async () => {
setLoading(true);
const data = await changePassword({
@ -93,29 +110,70 @@ export default function UserMenu() {
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
Change password
</Menu.Item>
<Menu.Item component={Link} to={Links.GENERATE_KEYS} leftSection={<IconKey size={14} />}>
<Menu.Item
component={Link}
to={Links.GENERATE_KEYS}
leftSection={<IconKey size={14} />}
>
Keys
</Menu.Item>
<Menu.Item
component={Link}
to={Links.CONFIGS}
leftSection={<IconCode size={14} />}
>
Configs
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={handleLogout} color="red" leftSection={<IconLogout size={14} />}>
<Menu.Item
onClick={handleLogout}
color="red"
leftSection={<IconLogout size={14} />}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Modal className="relative" opened={opened} onClose={close} title="Change password" centered>
<form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
<PasswordInput size="sm" label="Current password" {...form.getInputProps('currentPassword')} />
<PasswordInput size="sm" label="New password" {...form.getInputProps('newPassword')} />
<PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
<Modal
className="relative"
opened={opened}
onClose={close}
title="Change password"
centered
>
<form
onSubmit={form.onSubmit(handleSubmit)}
className="flex flex-col gap-2.5"
>
<PasswordInput
size="sm"
label="Current password"
{...form.getInputProps("currentPassword")}
/>
<PasswordInput
size="sm"
label="New password"
{...form.getInputProps("newPassword")}
/>
<PasswordInput
size="sm"
label="Confirm password"
{...form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth size="sm" mt="md">
Change
</Button>
</form>
<LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
</>
);

View File

@ -0,0 +1,191 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
Button,
LoadingOverlay,
Modal,
ModalProps,
Select,
Textarea,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import _ from "lodash";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { createScrapConfig, updateScrapConfig } from "../../apis/scrap";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IScrapConfig, IWebBid } from "../../system/type";
export interface IScrapConfigModelProps extends ModalProps {
data: IWebBid | null;
onUpdated?: () => void;
}
const schema = z.object({
search_url: z
.string()
.url({ message: "Url is invalid" })
.min(1, { message: "Url is required" }),
keywords: z
.string({ message: "Keyword is required" })
.min(1, { message: "Keyword is required" })
.optional(),
enable: z.enum(["1", "0"], { required_error: "Enable is required" }),
});
export default function ScrapConfigModal({
data,
onUpdated,
...props
}: IScrapConfigModelProps) {
const form = useForm<IScrapConfig>({
validate: zodResolver(schema),
});
const [loading, setLoading] = useState(false);
const prevData = useRef<IScrapConfig | null>(data?.scrap_config);
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data?.scrap_config) {
setConfirm({
title: "Update ?",
message: `This config will be update`,
handleOk: async () => {
setLoading(true);
const result = await updateScrapConfig({
...values,
id: data.scrap_config.id,
});
setLoading(false);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: "blue",
value: "Update",
},
});
} else {
setLoading(true);
const result = await createScrapConfig({
...values,
web_id: data?.id || 0,
});
setLoading(false);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
const values = {
...data.scrap_config,
enable: (data.scrap_config?.enable === undefined
? "1"
: data.scrap_config.enable
? "1"
: "0") as "0" | "1",
};
form.setValues(values);
prevData.current = values;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
return (
<Modal
className="relative"
classNames={{
header: "!flex !item-center !justify-center w-full",
}}
{...props}
size={"xl"}
title={<span className="text-xl font-bold">Scrap config</span>}
centered
>
<form
onSubmit={form.onSubmit(handleSubmit)}
className="grid grid-cols-2 gap-2.5"
>
<Select
className="col-span-2"
label="Enable scrape"
defaultChecked={true}
defaultValue={"1"}
data={[
{
label: "Enbale",
value: "1",
},
{
label: "Disable",
value: "0",
},
]}
{...form.getInputProps("enable")}
/>
<TextInput
className="col-span-2"
size="sm"
label="Search url"
withAsterisk
description="Replace query keyword in url with phrase {{keyword}}"
placeholder="https://www.abc.com/search?q={{keyword}}"
{...form.getInputProps("search_url")}
/>
<Textarea
className="col-span-2"
size="sm"
label="Keywords"
rows={4}
placeholder="msg: Cisco"
description={"Different keywords must be separated by commas."}
{...form.getInputProps("keywords")}
/>
<Button
disabled={_.isEqual(form.getValues(), prevData.current)}
className="col-span-2"
type="submit"
fullWidth
size="sm"
mt="md"
>
{data?.scrap_config ? "Update" : "Create"}
</Button>
</form>
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
);
}

View File

@ -1,23 +1,36 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, LoadingOverlay, Modal, ModalProps, PasswordInput, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { z } from 'zod';
import { updateWebBid } from '../../apis/web-bid';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IWebBid } from '../../system/type';
import {
Button,
LoadingOverlay,
Modal,
ModalProps,
PasswordInput,
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import _ from "lodash";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { updateWebBid } from "../../apis/web-bid";
import { useConfirmStore } from "../../lib/zustand/use-confirm";
import { IWebBid } from "../../system/type";
export interface IWebBidModelProps extends ModalProps {
data: IWebBid | null;
onUpdated?: () => void;
}
const schema = z.object({
username: z.string().min(1, { message: 'Username is required' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters long' }),
username: z.string().min(1, { message: "Username is required" }),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters long" }),
});
export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidModelProps) {
export default function WebAccountModal({
data,
onUpdated,
...props
}: IWebBidModelProps) {
const form = useForm({
validate: zodResolver(schema),
});
@ -31,7 +44,7 @@ export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidMo
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
title: "Update ?",
message: `This account will be update`,
handleOk: async () => {
setLoading(true);
@ -47,8 +60,8 @@ export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidMo
}
},
okButton: {
color: 'blue',
value: 'Update',
color: "blue",
value: "Update",
},
});
} else {
@ -87,23 +100,49 @@ export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidMo
<Modal
className="relative"
classNames={{
header: '!flex !item-center !justify-center w-full',
header: "!flex !item-center !justify-center w-full",
}}
{...props}
size={'xl'}
size={"xl"}
title={<span className="text-xl font-bold">Account</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
<TextInput className="col-span-2" size="sm" label="Username" {...form.getInputProps('username')} />
<PasswordInput className="col-span-2" size="sm" label="Password" {...form.getInputProps('password')} />
<form
onSubmit={form.onSubmit(handleSubmit)}
className="grid grid-cols-2 gap-2.5"
>
<TextInput
withAsterisk
className="col-span-2"
size="sm"
label="Username"
{...form.getInputProps("username")}
/>
<PasswordInput
withAsterisk
className="col-span-2"
size="sm"
label="Password"
{...form.getInputProps("password")}
/>
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{data ? 'Update' : 'Create'}
<Button
disabled={_.isEqual(form.getValues(), prevData.current)}
className="col-span-2"
type="submit"
fullWidth
size="sm"
mt="md"
>
{data ? "Update" : "Create"}
</Button>
</form>
<LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
);
}

View File

@ -26,12 +26,14 @@ const schema = {
.number({ message: "Arrival offset seconds is required" })
.refine((val) => val >= 60, {
message: "Arrival offset seconds must be at least 60 seconds (1 minute)",
}),
})
.optional(),
early_tracking_seconds: z
.number({ message: "Early login seconds is required" })
.refine((val) => val >= 600, {
message: "Early login seconds must be at least 600 seconds (10 minute)",
}),
})
.optional(),
};
export default function WebBidModal({
@ -56,11 +58,7 @@ export default function WebBidModal({
message: `This web will be update`,
handleOk: async () => {
setLoading(true);
console.log(
"%csrc/components/web-bid/web-bid-modal.tsx:54 values",
"color: #007acc;",
values
);
const result = await updateWebBid(values);
setLoading(false);
@ -78,14 +76,19 @@ export default function WebBidModal({
},
});
} else {
const { url, origin_url, arrival_offset_seconds, early_tracking_seconds } = values;
const {
url,
origin_url,
arrival_offset_seconds,
early_tracking_seconds,
} = values;
setLoading(true);
const result = await createWebBid({
url,
origin_url,
arrival_offset_seconds,
early_tracking_seconds
early_tracking_seconds,
} as IWebBid);
setLoading(false);
@ -157,9 +160,9 @@ export default function WebBidModal({
description="Note: that only integer minutes are accepted."
className="col-span-2"
size="sm"
label={`Arrival offset seconds (${
formatTimeFromMinutes(form.getValues()["arrival_offset_seconds"] / 60)
})`}
label={`Arrival offset seconds (${formatTimeFromMinutes(
form.getValues()["arrival_offset_seconds"] / 60
)})`}
placeholder="msg: 300"
{...form.getInputProps("arrival_offset_seconds")}
/>
@ -167,9 +170,9 @@ export default function WebBidModal({
description="Note: that only integer minutes are accepted."
className="col-span-2"
size="sm"
label={`Early tracking seconds (${
formatTimeFromMinutes(form.getValues()["early_tracking_seconds"] / 60)
})`}
label={`Early tracking seconds (${formatTimeFromMinutes(
form.getValues()["early_tracking_seconds"] / 60
)})`}
placeholder="msg: 600"
{...form.getInputProps("early_tracking_seconds")}
/>

View File

@ -1,8 +1,13 @@
const constants = {
grays: 'https://www.grays.com',
pickles:'https://www.pickles.com.au'
}
grays: "https://www.grays.com",
pickles: "https://www.pickles.com.au",
allbids: "https://www.allbids.com.au",
};
export const haveHistories = [constants.grays, constants.pickles]
export const haveHistories = [
constants.grays,
constants.pickles,
constants.allbids,
];
export default constants
export default constants;

View File

@ -116,5 +116,3 @@ export const removeFalsy = (data: { [key: string]: string | number }) => {
return prev;
}, {} as { [key: string]: string | number });
};

View File

@ -12,31 +12,39 @@ import { useDisclosure } from "@mantine/hooks";
import {
IconAd,
IconAdOff,
IconCode,
IconEdit,
IconHammer,
IconHistory,
IconMenu,
IconPlayerRecord,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import _ from "lodash";
import { useMemo, useRef, useState } from "react";
import { deleteBid, deletesBid, getBids, toggleBid } from "../apis/bid";
import { deleteBid, getBids, toggleBid } from "../apis/bid";
import {
BidModal,
ShowHistoriesBidGraysApiModal,
ShowHistoriesBidPicklesApiModal,
RecordModal,
ResponseDemoModal,
ShowHistoriesModal,
} from "../components/bid";
import constants, { haveHistories } from "../constant";
import DeleteRowAction from "../components/bid/delete-row-action";
import ShowHistoriesApiModal from "../components/bid/show-histories-api/show-histories-api-modal";
import { haveHistories } from "../constant";
import Table from "../lib/table/table";
import { IColumn, TRefTableFn } from "../lib/table/type";
import { useChoosesStore } from "../lib/zustand/use-chooses-store";
import { useConfirmStore } from "../lib/zustand/use-confirm";
import { mappingStatusColors } from "../system/constants";
import { IBid } from "../system/type";
import { extractDomainSmart, formatTime } from "../utils";
import DeleteRowAction from "../components/bid/delete-row-action";
import { useChoosesStore } from "../lib/zustand/use-chooses-store";
import {
extractDomainSmart,
formatTime,
getMode,
getResponseDemo,
} from "../utils";
export default function Bids() {
const refTableFn: TRefTableFn<IBid> = useRef({});
@ -47,13 +55,12 @@ export default function Bids() {
const { setChooses } = useChoosesStore();
const [openedHistories, historiesModel] = useDisclosure(false);
const [openedHistoriesGraysApi, historiesGraysApiModel] =
useDisclosure(false);
const [openedHistories, historiesModal] = useDisclosure(false);
const [openedHistoriesPicklesApi, historiesPicklesApiModel] =
useDisclosure(false);
const [openedHistoriesView, openedHistoriesViewModal] = useDisclosure(false);
const [openedBid, bidModal] = useDisclosure(false);
const [openedRecord, recordModal] = useDisclosure(false);
const [openedResponseDemo, responseDemoModal] = useDisclosure(false);
const columns: IColumn<IBid>[] = [
{
@ -84,7 +91,9 @@ export default function Bids() {
title: "Web",
typeFilter: "none",
renderRow(row) {
return <span>{extractDomainSmart(row.web_bid.origin_url)}</span>;
return (
<span>{extractDomainSmart(row.web_bid?.origin_url) || "None"}</span>
);
},
},
{
@ -145,7 +154,7 @@ export default function Bids() {
},
},
{
key: "close_time",
key: "close_time_ts",
title: "Close time",
typeFilter: "date",
renderRow(row) {
@ -190,6 +199,29 @@ export default function Bids() {
);
},
},
{
key: "metadata",
title: "Mode",
typeFilter: "none",
style: {
style: {
minWidth: "100px",
},
},
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge
color={getMode(row) === "live" ? "teal" : "orange"}
size="sm"
>
{row.metadata.find((item) => item.key_name === "mode_key")
?.value || "Live"}
</Badge>
</Box>
);
},
},
];
const handleDelete = (bid: IBid) => {
@ -238,28 +270,6 @@ export default function Bids() {
}}
actionsOptions={{
showMainAction: false,
actions: [
{
key: "delete",
title: "Delete",
callback: (data) => {
if (!data.length) return;
setConfirm({
title: "Delete",
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesBid(data);
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
},
});
},
disabled: (data) => data.length <= 0,
},
],
leftActionSession: (
<Box className="flex items-end gap-2">
<Button
@ -270,7 +280,11 @@ export default function Bids() {
Add
</Button>
<DeleteRowAction onDeleted={refTableFn.current?.fetchData} />
<DeleteRowAction
onDeleted={() => {
refTableFn.current?.fetchData?.();
}}
/>
</Box>
),
}}
@ -329,29 +343,17 @@ export default function Bids() {
<Menu.Item
onClick={() => {
setClickData(row);
historiesModel.open();
historiesModal.open();
}}
leftSection={<IconHistory size={14} />}
>
Histories
</Menu.Item>
{haveHistories.includes(row?.web_bid.origin_url) && (
{haveHistories.includes(row?.web_bid?.origin_url) && (
<Menu.Item
onClick={() => {
setClickData(row);
switch (row.web_bid.origin_url) {
case constants.grays: {
historiesGraysApiModel.open();
break;
}
case constants.pickles: {
historiesPicklesApiModel.open();
break;
}
default: {
historiesGraysApiModel.open();
}
}
openedHistoriesViewModal.open();
}}
leftSection={<IconHammer size={14} />}
>
@ -373,12 +375,26 @@ export default function Bids() {
{row.status === "biding" ? "Disable" : "Enable"}
</Menu.Item>
{/* <Menu.Item
onClick={() => handleDelete(row)}
leftSection={<IconTrash color="red" size={14} />}
<Menu.Item
onClick={() => {
setClickData(row);
if (getResponseDemo(row)) {
responseDemoModal.open();
} else {
recordModal.open();
}
}}
leftSection={
getResponseDemo(row) ? (
<IconCode size={14} />
) : (
<IconPlayerRecord size={14} />
)
}
>
Delete
</Menu.Item> */}
{getResponseDemo(row) ? "Response demo" : "Record"}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@ -405,11 +421,12 @@ export default function Bids() {
<ShowHistoriesModal
opened={openedHistories}
onClose={() => {
historiesModel.close();
historiesModal.close();
setClickData(null);
}}
data={clickData}
/>
<BidModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
@ -426,9 +443,8 @@ export default function Bids() {
}}
data={clickData}
/>
{/* Grays */}
{openedHistoriesGraysApi && (
<ShowHistoriesBidGraysApiModal
<RecordModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
@ -436,18 +452,34 @@ export default function Bids() {
setClickData(null);
}}
opened={openedHistoriesGraysApi}
opened={openedRecord}
onClose={() => {
historiesGraysApiModel.close();
recordModal.close();
setClickData(null);
}}
data={clickData}
/>
)}
{openedHistoriesPicklesApi && (
<ShowHistoriesBidPicklesApiModal
<ResponseDemoModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
setClickData(null);
}}
opened={openedResponseDemo}
onClose={() => {
responseDemoModal.close();
setClickData(null);
}}
data={clickData}
/>
{openedHistoriesView && (
<ShowHistoriesApiModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
@ -457,7 +489,7 @@ export default function Bids() {
}}
opened={true}
onClose={() => {
historiesPicklesApiModel.close();
openedHistoriesViewModal.close();
setClickData(null);
}}
data={clickData}

View File

@ -0,0 +1,10 @@
import { Box } from "@mantine/core";
import MailsConfig from "../components/config/mails-config";
export default function Configs() {
return (
<Box className="flex">
<MailsConfig />
</Box>
);
}

View File

@ -36,6 +36,7 @@ export default function DashBoard() {
const RETRY_CONNECT = useRef(2);
useEffect(() => {
setLoading(true);
socket.connect();
socket.on("connect", () => {
@ -78,6 +79,7 @@ export default function DashBoard() {
setWorkingData(newData);
});
setLoading(false);
return () => {
console.log("🔌 Cleanup WebSocket listeners...");
socket.off("adminBidsUpdated");

View File

@ -1,5 +1,6 @@
export { default as Dashboard } from './dashboard';
export { default as Bids } from './bids';
export { default as OutBidsLog } from './out-bids-log';
export { default as Login } from './login';
export { default as App } from './app';
export { default as Dashboard } from "./dashboard";
export { default as Bids } from "./bids";
export { default as OutBidsLog } from "./out-bids-log";
export { default as Login } from "./login";
export { default as App } from "./app";
export { default as Configs } from "./configs";

View File

@ -1,14 +1,28 @@
import { ActionIcon, Badge, Box, Menu, Text } from '@mantine/core';
import { IconAd, IconAdOff, IconEdit, IconMenu, IconTrash, IconUserEdit } from '@tabler/icons-react';
import { useMemo, useRef, useState } from 'react';
import { deletesWebBid, deleteWebBid, getWebBids, updateWebBid } from '../apis/web-bid';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import { IWebBid } from '../system/type';
import { formatTime } from '../utils';
import { WebAccountModal, WebBidModal } from '../components/web-bid';
import { useDisclosure } from '@mantine/hooks';
import { ActionIcon, Badge, Box, Menu, Text } from "@mantine/core";
import {
IconAd,
IconAdOff,
IconEdit,
IconMenu,
IconSettingsCode,
IconTrash,
IconUserEdit,
} from "@tabler/icons-react";
import { useMemo, useRef, useState } from "react";
import {
deletesWebBid,
deleteWebBid,
getWebBids,
updateWebBid,
} from "../apis/web-bid";
import Table from "../lib/table/table";
import { IColumn, TRefTableFn } from "../lib/table/type";
import { useConfirmStore } from "../lib/zustand/use-confirm";
import { IWebBid } from "../system/type";
import { formatTime } from "../utils";
import { WebAccountModal, WebBidModal } from "../components/web-bid";
import { useDisclosure } from "@mantine/hooks";
import ScrapConfigModal from "../components/web-bid/scrap-config.modal";
export default function WebBids() {
const refTableFn: TRefTableFn<IWebBid> = useRef({});
@ -19,35 +33,36 @@ export default function WebBids() {
const [webBidOpened, webBidModal] = useDisclosure(false);
const [webAccountOpened, webAccountModal] = useDisclosure(false);
const [scrapConfigOpened, scrapConfigModal] = useDisclosure(false);
const columns: IColumn<IWebBid>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
key: "id",
title: "ID",
typeFilter: "number",
},
{
key: 'origin_url',
title: 'Domain',
typeFilter: 'text',
key: "origin_url",
title: "Domain",
typeFilter: "text",
},
{
key: 'url',
title: 'Tracking url',
typeFilter: 'text',
key: "url",
title: "Tracking url",
typeFilter: "text",
renderRow(row) {
return <Text>{row.url || 'None'}</Text>;
return <Text>{row.url || "None"}</Text>;
},
},
{
key: 'active',
title: 'Status',
typeFilter: 'text',
key: "active",
title: "Status",
typeFilter: "text",
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge color={row.active ? 'green' : 'red'} size="sm">
{row.active ? 'Enable' : 'Disable'}
<Badge color={row.active ? "green" : "red"} size="sm">
{row.active ? "Enable" : "Disable"}
</Badge>
</Box>
);
@ -55,17 +70,17 @@ export default function WebBids() {
},
{
key: 'created_at',
title: 'Created at',
typeFilter: 'none',
key: "created_at",
title: "Created at",
typeFilter: "none",
renderRow(row) {
return <span>{formatTime(row.created_at)}</span>;
},
},
{
key: 'updated_at',
title: 'Update at',
typeFilter: 'none',
key: "updated_at",
title: "Update at",
typeFilter: "none",
renderRow(row) {
return <span>{formatTime(row.updated_at)}</span>;
},
@ -74,8 +89,8 @@ export default function WebBids() {
const handleDelete = (data: IWebBid) => {
setConfirm({
title: 'Delete ?',
message: 'This web will be delete',
title: "Delete ?",
message: "This web will be delete",
handleOk: async () => {
await deleteWebBid(data);
@ -88,8 +103,8 @@ export default function WebBids() {
const handleToggle = async (data: IWebBid) => {
setConfirm({
title: (data.active ? 'Disable ' : 'Enable ') + 'ID: ' + data.id,
message: 'This web will be ' + (data.active ? 'disable ' : 'enable '),
title: (data.active ? "Disable " : "Enable ") + "ID: " + data.id,
message: "This web will be " + (data.active ? "disable " : "enable "),
handleOk: async () => {
await updateWebBid({ ...data, active: !data.active || false });
@ -98,8 +113,8 @@ export default function WebBids() {
}
},
okButton: {
value: data.active ? 'Disable ' : 'Enable ',
color: data.active ? 'red' : 'blue',
value: data.active ? "Disable " : "Enable ",
color: data.active ? "red" : "blue",
},
});
};
@ -110,19 +125,19 @@ export default function WebBids() {
actionsOptions={{
actions: [
{
key: 'add',
title: 'Add',
key: "add",
title: "Add",
callback: () => {
webBidModal.open();
},
},
{
key: 'delete',
title: 'Delete',
key: "delete",
title: "Delete",
callback: (data) => {
if (!data.length) return;
setConfirm({
title: 'Delete',
title: "Delete",
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesWebBid(data);
@ -143,18 +158,18 @@ export default function WebBids() {
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
justifyContent: "flex-start",
width: "fit-content",
}}
options={{
query: getWebBids,
pathToData: 'data.data',
pathToData: "data.data",
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
last_page: "lastPage",
per_page: "perPage",
from: "from",
to: "to",
total: "total",
},
}}
rows={[]}
@ -195,11 +210,33 @@ export default function WebBids() {
Account
</Menu.Item>
<Menu.Item onClick={() => handleToggle(row)} leftSection={row.active ? <IconAdOff size={14} /> : <IconAd size={14} />}>
{row.active ? 'Disable' : 'Enable'}
<Menu.Item
onClick={() => {
setClickData(row);
scrapConfigModal.open();
}}
leftSection={<IconSettingsCode size={14} />}
>
Scrap config
</Menu.Item>
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
<Menu.Item
onClick={() => handleToggle(row)}
leftSection={
row.active ? (
<IconAdOff size={14} />
) : (
<IconAd size={14} />
)
}
>
{row.active ? "Disable" : "Enable"}
</Menu.Item>
<Menu.Item
onClick={() => handleDelete(row)}
leftSection={<IconTrash color="red" size={14} />}
>
Delete
</Menu.Item>
</Menu.Dropdown>
@ -248,6 +285,22 @@ export default function WebBids() {
}
}}
/>
<ScrapConfigModal
data={clickData}
opened={scrapConfigOpened}
onClose={() => {
scrapConfigModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
</Box>
);
}

View File

@ -1,70 +1,87 @@
import { IconHammer, IconHome2, IconKey, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
import { Bids, Dashboard, OutBidsLog } from '../pages';
import WebBids from '../pages/web-bids';
import SendMessageHistories from '../pages/send-message-histories';
import Admins from '../pages/admins';
import GenerateKeys from '../pages/generate-keys';
import {
IconHammer,
IconHome2,
IconKey,
IconMessage,
IconOutlet,
IconPageBreak,
IconSettings,
IconUserCheck,
} from "@tabler/icons-react";
import { Bids, Configs, Dashboard, OutBidsLog } from "../pages";
import WebBids from "../pages/web-bids";
import SendMessageHistories from "../pages/send-message-histories";
import Admins from "../pages/admins";
import GenerateKeys from "../pages/generate-keys";
export default class Links {
public static DASHBOARD = '/dashboard';
public static BIDS = '/bids';
public static WEBS = '/webs';
public static OUT_BIDS_LOG = '/out-bids-log';
public static SEND_MESSAGE_HISTORIES = '/send-message-histories';
public static GENERATE_KEYS = '/generate-keys';
public static ADMINS = '/admins';
public static DASHBOARD = "/dashboard";
public static BIDS = "/bids";
public static WEBS = "/webs";
public static OUT_BIDS_LOG = "/out-bids-log";
public static SEND_MESSAGE_HISTORIES = "/send-message-histories";
public static GENERATE_KEYS = "/generate-keys";
public static ADMINS = "/admins";
public static CONFIGS = "/configs";
public static HOME = '/';
public static LOGIN = '/login';
public static HOME = "/";
public static LOGIN = "/login";
public static MENUS = [
{
path: this.DASHBOARD,
title: 'Dashboard',
title: "Dashboard",
icon: IconHome2,
element: Dashboard,
show: true,
},
{
path: this.ADMINS,
title: 'Admins',
title: "Admins",
icon: IconUserCheck,
element: Admins,
show: true,
},
{
path: this.WEBS,
title: 'Webs',
title: "Webs",
icon: IconPageBreak,
element: WebBids,
show: true,
},
{
path: this.BIDS,
title: 'Bids',
title: "Bids",
icon: IconHammer,
element: Bids,
show: true,
},
{
path: this.OUT_BIDS_LOG,
title: 'Out bids log',
title: "Out bids log",
icon: IconOutlet,
element: OutBidsLog,
show: true,
},
{
path: this.SEND_MESSAGE_HISTORIES,
title: 'Send message histories',
title: "Send message histories",
icon: IconMessage,
element: SendMessageHistories,
show: true,
},
{
path: this.GENERATE_KEYS,
title: 'Generate keys',
title: "Generate keys",
icon: IconKey,
element: GenerateKeys,
show: false,
},
{
path: this.CONFIGS,
title: "Configs",
icon: IconSettings,
element: Configs,
show: false,
},
];
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface IAdmin extends ITimestamp {
id: number;
email: string;
@ -18,8 +19,6 @@ export interface ITimestamp {
updated_at: string;
}
export interface IHistory extends ITimestamp {
id: number;
price: number;
@ -33,6 +32,22 @@ export interface IOutBidLog extends ITimestamp {
raw_data: string;
}
export interface IScrapConfig extends ITimestamp {
id: number;
search_url: string;
keywords: string;
enable: boolean | "0" | "1";
scrap_items: IScrapItem[];
}
export interface IScrapItem extends ITimestamp {
id: number;
url: string;
model: string;
image_url: string | null;
keyword: string;
}
export interface IWebBid extends ITimestamp {
created_at: string;
updated_at: string;
@ -44,8 +59,15 @@ export interface IWebBid extends ITimestamp {
active: boolean;
arrival_offset_seconds: number;
early_tracking_seconds: number;
snapshot_at: string | null
snapshot_at: string | null;
children: IBid[];
scrap_config: IScrapConfig;
}
export interface IMetadata extends ITimestamp {
id: number;
key_name: string;
value: any;
}
export interface IBid extends ITimestamp {
@ -60,11 +82,20 @@ export interface IBid extends ITimestamp {
lot_id: string;
plus_price: number;
close_time: string | null;
close_time_ts: string | null;
start_bid_time: string | null;
first_bid: boolean;
status: 'biding' | 'out-bid' | 'win-bid';
status: "biding" | "out-bid" | "win-bid";
histories: IHistory[];
web_bid: IWebBid;
metadata: IMetadata[];
}
export interface IConfig extends ITimestamp {
id: number;
key_name: string;
value: string;
type: "string" | "number";
}
export interface IPermission extends ITimestamp {

View File

@ -3,8 +3,8 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import moment from "moment";
import { IWebBid } from "../system/type";
import _ from 'lodash'
import { IBid, IMetadata, IWebBid } from "../system/type";
import _ from "lodash";
export function cn(...args: ClassValue[]) {
return twMerge(clsx(args));
@ -204,7 +204,6 @@ export function extractDomainSmart(url: string) {
}
}
export function findNearestClosingChild(webBid: IWebBid) {
const now = Date.now();
@ -255,11 +254,37 @@ export function formatTimeFromMinutes(minutes: number): string {
const hours = Math.floor((minutes % (60 * 24)) / 60);
const mins = minutes % 60;
let result = '';
let result = "";
if (days > 0) result += `${days} ${days > 1? 'days' :'day'} `;
if (hours > 0) result += `${hours} ${hours > 1 ? 'hours' : 'hour'} `;
if (mins > 0 || result === '') result += `${mins} minutes`;
if (days > 0) result += `${days} ${days > 1 ? "days" : "day"} `;
if (hours > 0) result += `${hours} ${hours > 1 ? "hours" : "hour"} `;
if (mins > 0 || result === "") result += `${mins} minutes`;
return result.trim();
}
export const getMode = (row: IBid) => {
return (
row.metadata.find((item) => item.key_name === "mode_key")?.value || "live"
);
};
export const getEarlyTrackingSeconds = (row: IBid) => {
const mode = getMode(row);
return (
row.metadata.find(
(item) => item.key_name === `early_tracking_seconds_${mode}`
)?.value || row.web_bid.early_tracking_seconds
);
};
export const getResponseDemo = (data: IBid) => {
if (!data?.metadata) return null;
return (
(data?.metadata.find(
(item) => item.key_name === "demo_response"
) as IMetadata) || null
);
};

View File

@ -1,178 +0,0 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
<title>Callcentric Email</title>
<style type=3D"text/css" media=3D"all">
body {
font-family: arial,helvetica,sans-serif;
font-size: 14px;
line-height: 22px;
color: #444;
margin: 0;
padding: 20px 0;
background-color: #e5e5e5;
outline: 0;
vertical-align: top;
}
p {
margin-bottom: 1.5em;
}
img {
display: block;
}
a:link,
a:visited {
color: #006699;
text-decoration: underline;
margin: 0px;
padding: 0px;
}
a:hover {=20
text-decoration: none;
}
li {
margin-bottom: 1em;
line-height: 22px;
}=09
=2Efooter a {
color: #aaaaaa;
text-decoration: underline;
}
=2Efooter a:hover {
text-decoration: none;
}
=2Eframe {
width: 580px;
}
#btn {
background: #3498db;
background-image: -webkit-linear-gradient(top, #3498db, #2980b9);
background-image: -moz-linear-gradient(top, #3498db, #2980b9);
background-image: -ms-linear-gradient(top, #3498db, #2980b9);
background-image: -o-linear-gradient(top, #3498db, #2980b9);
background-image: linear-gradient(to bottom, #3498db, #2980b9);
-webkit-border-radius: 28;
-moz-border-radius: 28;
border-radius: 28px;
font-family: Arial;
color: #ffffff;
font-size: 20px;
padding: 10px 20px 10px 20px;
text-decoration: none;
display: inline-block;
}
#btn:hover {
background: #3cb0fd;
background-image: -webkit-linear-gradient(top, #3cb0fd, #3498db);
background-image: -moz-linear-gradient(top, #3cb0fd, #3498db);
background-image: -ms-linear-gradient(top, #3cb0fd, #3498db);
background-image: -o-linear-gradient(top, #3cb0fd, #3498db);
background-image: linear-gradient(to bottom, #3cb0fd, #3498db);
text-decoration: none;
}
=2Esms-box { font-family: arial,helvetica,sans-serif; font-size: 14px; padd=
ing:12px; border:1px solid #e0e0e0; background-color: #e0e0e0; -webkit-bord=
er-radius: 12px; -moz-border-radius: 12px; border-radius: 12px; word-wrap:b=
reak-word; display:inline-block; }
p { font-family: arial,helvetica,sans-serif; font-size: 14px; margin-bottom=
: 1.5em; line-height: 22px; }
@media only screen and (max-device-width: 480px) {
.frame {
width: 442px !important;
}
p {
font-size: 22px !important;
line-height: 28px !important;
}
ul li {
font-size: 22px !important;
line-height: 28px !important;
}
.section_header{
font-size: 28px !important;
line-height: 30px !important;
}
#btn {
font-size: 28px !important;
padding: 14px 24px 14px 24px !important;
}
}
</style>
</head>
<body style=3D"font-family: Arial, sans-serif; font-size: 14px; margin-bott=
om: 1.5em; line-height: 22px; color: black; width: 100% !important; margin:=
0; padding: 0px; background-color: #e5e5e5; outline: 0; vertical-align: to=
p;" bgcolor=3D"#e5e5e5">
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0"><tr>=
<td style=3D"padding: 0px 0px;" align=3D"center">
<table class=3D"head" width=3D"100%" bgcolor=3D"#c9c9c9" style=3D"backgroun=
d-color:#c9c9c9;">
<tr><td align=3D"center" style=3D"color:#f3f3f3; padding:5px; font-size:12=
px;">Please do not respond to this automated message, you will not receive =
a reply</td></tr>
</table>
</td></tr><tr><td style=3D"padding: 0px 10px;" align=3D"center">
<table class=3D"frame" width=3D"580" cellpadding=3D"0" cellspacing=3D"0" bo=
rder=3D"0" align=3D"center"><tbody>
<tr><td>
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr><td style=3D"padding: 30px 0px 10px 0px; color: #444; text-align: le=
ft; vertical-align: middle;" align=3D"left"><a href=3D"https://www.callcent=
ric.com"><img src=3D"https://www.callcentric.com/img/callcentric_logo_250_t=
rans.png" width=3D"125" height=3D"33" alt=3D"Callcentric" border=3D"0"></a>=
</td></tr>
</table>
</td></tr>
<tr><td style=3D"background-color: #ffffff; padding: 0px 0px 50px; border-=
radius: 5px 5px 0px 0px;">
<table style=3D"padding-top: 40px;" width=3D"100%" cellpadding=3D"0" cell=
spacing=3D"0" border=3D"0"><tbody>
<tr><td class=3D"flex-column" style=3D"width: 40px;"></td>
<td style=3D"text-align: left;" align=3D"left">
<p>Dear Morgan Vu,</p><p>You have received an inbound text message from 223=
95 on your DID 17146893350:</p><div class=3D"sms-box">Your LANGTONS verific=
ation code is: 012152. This code will expire in 10 minutes.</div>
</td><td class=3D"flex-column" style=3D"width: 40px;"></td></tr>
</tbody></table>
</td></tr>
<tr><td align=3D"left" class=3D"footer" style=3D"background-color: #f2f2f2=
; color: #999999; padding: 0px 0px 40px; border-radius: 0px 0px 5px 5px;">
<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"padding-=
top: 40px;" width=3D"100%"><tbody>
<tr><td style=3D"width: 40px;"></td>
<td align=3D"left" style=3D"text-align: left; font-family:Arial, Helvet=
ica, sans-serif; font-size: 15px; line-height: 22px; color: #999999;">
Follow us on Twitter at:<br>
<a href=3D"https://twitter.com/Callcentric">https://twitter.com/Callcentric=
</a>
<br><br>
If you have any questions/concerns, please contact our Customer Service Tea=
m via the following page:<br>
<a href=3D"https://www.callcentric.com/CustomerService">https://www.callcen=
tric.com/CustomerService</a><br><br>
For instructions on how to unsubscribe from our Newsletters/Service Announc=
ements please visit:<br>
<a href=3D"https://www.callcentric.com/faq/11/306">https://www.callcentric=
=2Ecom/faq/11/306</a><br><br>
<a href=3D"https://www.callcentric.com">www.callcentric.com</a><br><br>
</td><td style=3D"width: 40px;"></td>
</tr>
</tbody></table>
</td></tr>
<tr><td style=3D"padding: 20px 0; font-size: 15px; line-height: 25px; colo=
r: #999999; text-align: center;" align=3D"center"></td></tr>
</tbody></table></td></tr></table>
</body>
</html>

View File

@ -1 +1 @@
{"createdAt":1747011314493}
{"createdAt":1752455341798}

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,8 @@
"migration:revert": "npm run typeorm -- -d ./src/config/typeorm.ts migration:revert"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/bull": "^11.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.1",
"@nestjs/core": "^10.0.0",
@ -33,21 +35,26 @@
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-socket.io": "^11.0.11",
"@nestjs/schedule": "^6.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.0.11",
"axios": "^1.8.3",
"bcrypt": "^5.1.1",
"bull": "^4.16.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"ioredis": "^5.6.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",
"nodemailer": "^7.0.3",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
@ -67,7 +74,7 @@
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.16",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/node": "^20.17.46",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",

View File

@ -14,6 +14,9 @@ import {
import { AuthorizationMiddleware } from './modules/admins/middlewares/authorization.middleware';
import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/client-authentication.middleware';
import { NotificationModule } from './modules/notification/notification.module';
import { ScrapsModule } from './modules/scraps/scraps.module';
import { ExtensionsModule } from './modules/extensions/extensions.module';
import { MailsModule } from './modules/mails/mails.module';
@Module({
imports: [
@ -24,6 +27,9 @@ import { NotificationModule } from './modules/notification/notification.module';
AuthModule,
AdminsModule,
NotificationModule,
ScrapsModule,
ExtensionsModule,
MailsModule,
],
controllers: [],
providers: [],

View File

@ -5,14 +5,25 @@ import { BadRequestExceptionFilter } from './system/filters/bad-request-exceptio
import { IoAdapter } from '@nestjs/platform-socket.io';
import * as cookieParser from 'cookie-parser';
import { useContainer } from 'class-validator';
import { WebBidsService } from './modules/bids/services/web-bids.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const prefix_version = '/api/v1';
const webBidService = app.get<WebBidsService>(WebBidsService);
const webs = await webBidService.webBidRepo.find();
const urls = webs.map((web) => web.origin_url);
const corsOrigins = process.env.CORS
? process.env.CORS.split(',').map((origin) => origin.trim())
: ['*']; // hoặc [] tùy ý bạn
app.enableCors({
origin: process.env.CORS.split(', ') || '*',
origin: [...corsOrigins, ...urls],
methods: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
allowedHeaders: 'Content-Type, Authorization',
credentials: true,

View File

@ -103,4 +103,5 @@ export class GenerateKeysService {
return AppResponse.toResponse(result);
}
}

View File

@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bull';
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
@ -11,6 +14,21 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
wildcard: true,
global: true,
}),
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
redis: {
host: configService.get<string>('REDIS_HOST'),
port: configService.get<number>('REDIS_PORT'),
},
}),
inject: [ConfigService],
}),
BullModule.registerQueue({
name: 'mail-queue',
}),
ScheduleModule.forRoot(),
],
exports: [BullModule],
})
export class AppConfigsModule {}

View File

@ -5,22 +5,17 @@ import { Bid } from '../entities/bid.entity';
import { BidsService } from '../services/bids.service';
@Injectable()
export class GraysApi {
export class AllBidsApi {
constructor(private readonly bidsService: BidsService) {}
async getHistoriesBid(lot_id: Bid['lot_id']) {
const bid= await this.bidsService.bidsRepo.findOne({where: {lot_id, }, relations: {web_bid: true}})
const bid = await this.bidsService.bidsRepo.findOne({
where: { lot_id },
relations: { web_bid: true },
});
try {
switch (bid.web_bid.origin_url) {
// GRAYS
case 'https://www.grays.com': {
const response = await axios({
@ -31,12 +26,11 @@ export class GraysApi {
return AppResponse.toResponse(response.data.Bids);
}
return AppResponse.toResponse([])
return AppResponse.toResponse([]);
}
// PICKLES
case 'https://www.pickles.com.au': {
const response = await axios({
url: `https://www.pickles.com.au/PWR-Web/services/api/bidHistoryService/bidHistory?item=${lot_id}`,
});
@ -45,13 +39,11 @@ export class GraysApi {
return AppResponse.toResponse(response.data.Bids);
}
return AppResponse.toResponse([])
return AppResponse.toResponse([]);
}
default:
return AppResponse.toResponse([])
return AppResponse.toResponse([]);
}
} catch (error) {
return AppResponse.toResponse([]);
}

View File

@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import AppResponse from 'src/response/app-response';
import { Bid } from '../entities/bid.entity';
import { BidsService } from '../services/bids.service';
import * as _ from 'lodash';
@Injectable()
export class AuctionHistoresApi {
constructor(private readonly bidsService: BidsService) {}
async getHistoriesBid(lot_id: Bid['lot_id']) {
const bid = await this.bidsService.bidsRepo.findOne({
where: { lot_id },
relations: { web_bid: true, metadata: true },
});
try {
switch (bid.web_bid.origin_url) {
// GRAYS
case 'https://www.grays.com': {
const response = await axios({
url: `https://www.grays.com/api/LotInfo/GetBiddingHistory?lotId=${lot_id}&currencyCode=AUD`,
});
if (response.data && response.data?.Bids) {
return AppResponse.toResponse(response.data.Bids);
}
return AppResponse.toResponse([]);
}
// PICKLES
case 'https://www.pickles.com.au': {
const response = await axios({
url: `https://www.pickles.com.au/PWR-Web/services/api/bidHistoryService/bidHistory?item=${lot_id}`,
});
if (response.data) {
return AppResponse.toResponse(response.data.Bids);
}
return AppResponse.toResponse([]);
}
// ALLBIDS
case 'https://www.allbids.com.au': {
const data = bid.metadata.find(
(meta) => meta.key_name === 'competor_histories',
)?.value;
const sorted = _.orderBy(data, ['amount'], ['desc']);
return AppResponse.toResponse(sorted || []);
}
default:
return AppResponse.toResponse([]);
}
} catch (error) {
return AppResponse.toResponse([]);
}
}
}

View File

@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { BidsService } from '../services/bids.service';
@Injectable()
export class HotItemApi {
constructor(
private readonly bidsService: BidsService,
private readonly configService: ConfigService,
) {}
listHotItem = async () => {
try {
const res = await axios({
method: 'GET',
baseURL: this.configService.get('NEW_ITEM_BASE_URL'),
url: '/disti/api/hotitem',
headers: {
// ...axios.defaults.headers.common,
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.configService.get('NEW_ITEM_TOKEN'),
},
});
return res.data.data || [];
} catch (error) {
return [];
}
};
}

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminsModule } from '../admins/admins.module';
import { NotificationModule } from '../notification/notification.module';
import { BotTelegramApi } from './apis/bot-telegram.api';
import { GraysApi } from './apis/grays.api';
import { AuctionHistoresApi } from './apis/auction-histories.api';
import { AdminBidHistoriesController } from './controllers/admin/admin-bid-histories.controller';
import { AdminBidsController } from './controllers/admin/admin-bids.controller';
import { AdminOutBidLogsController } from './controllers/admin/admin-out-bid-logs.controller';
@ -27,6 +27,15 @@ import { SendMessageHistoriesService } from './services/send-message-histories.s
import { WebBidsService } from './services/web-bids.service';
import { DashboardService } from './services/dashboard.service';
import { AdminDashboardController } from './controllers/admin/admin-dashboard.controller';
import { TasksService } from './services/tasks.servise';
import { ConfigsService } from './services/configs.service';
import { Config } from './entities/configs.entity';
import { AdminConfigsController } from './controllers/admin/admin-configs.controller';
import { BidMetadatasService } from './services/bid-metadatas.service';
import { BidMetadata } from './entities/bid-metadata.entity';
import { HotItemApi } from './apis/hot-item.api';
import { MailsModule } from '../mails/mails.module';
import { AdminBidMetadataController } from './controllers/admin/admin-bid-metadata.controller';
@Module({
imports: [
@ -36,6 +45,8 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
OutBidLog,
WebBid,
SendMessageHistory,
Config,
BidMetadata,
]),
// AuthModule,
AdminsModule,
@ -51,6 +62,8 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
AdminWebBidsController,
AdminSendMessageHistoriesController,
AdminDashboardController,
AdminConfigsController,
AdminBidMetadataController,
],
providers: [
BidsService,
@ -60,11 +73,24 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
OutBidLogsService,
WebBidsService,
BotTelegramApi,
GraysApi,
AuctionHistoresApi,
SendMessageHistoriesService,
ImapService,
DashboardService,
TasksService,
ConfigsService,
BidMetadatasService,
HotItemApi,
],
exports: [
BotTelegramApi,
SendMessageHistoriesService,
BidMetadatasService,
BidsService,
ConfigsService,
DashboardService,
HotItemApi,
WebBidsService,
],
exports: [BotTelegramApi, SendMessageHistoriesService, BidsService],
})
export class BidsModule {}

View File

@ -4,13 +4,13 @@ import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { BidHistoriesService } from '../../services/bid-histories.service';
import { CreateBidHistoryDto } from '../../dto/bid-history/create-bid-history.dto';
import { Bid } from '../../entities/bid.entity';
import { GraysApi } from '../../apis/grays.api';
import { AuctionHistoresApi } from '../../apis/auction-histories.api';
@Controller('admin/bid-histories')
export class AdminBidHistoriesController {
constructor(
private readonly bidHistoriesService: BidHistoriesService,
private readonly graysApi: GraysApi,
private readonly auctionHistoresApi: AuctionHistoresApi,
) {}
@Post()
@ -20,6 +20,6 @@ export class AdminBidHistoriesController {
@Get('detail/:lot_id')
async getBidHistories(@Param('lot_id') lot_id: Bid['lot_id']) {
return await this.graysApi.getHistoriesBid(lot_id);
return await this.auctionHistoresApi.getHistoriesBid(lot_id);
}
}

View File

@ -0,0 +1,21 @@
import { Body, Controller, Delete, Param, Post, Put } from '@nestjs/common';
import { BidMetadata } from '../../entities/bid-metadata.entity';
import { BidMetadatasService } from '../../services/bid-metadatas.service';
@Controller('admin/bid-metadatas')
export class AdminBidMetadataController {
constructor(private readonly bidMetadataService: BidMetadatasService) {}
@Delete(':id')
async delete(@Param('id') id: BidMetadata['id']) {
return await this.bidMetadataService.delete(id);
}
@Put(':id')
async update(
@Param('id') id: BidMetadata['id'],
@Body('value') value: string,
) {
return await this.bidMetadataService.update(id, value);
}
}

View File

@ -67,4 +67,19 @@ export class AdminBidsController {
) {
return await this.bidsService.getImagesWorking(id, type);
}
@Get('record/:name')
async getRecord(@Param('name') name: string, @Res() res: Response) {
return await this.bidsService.getRecord(name, res);
}
@Get('records/:id')
async getRecords(@Param('id') id: Bid['id']) {
return await this.bidsService.getRecords(id);
}
@Delete('record/:name')
async deleteRecord(@Param('name') name: string) {
return await this.bidsService.deleteRecord(name);
}
}

View File

@ -0,0 +1,20 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DashboardService } from '../../services/dashboard.service';
import { Config } from '../../entities/configs.entity';
import { ConfigsService } from '../../services/configs.service';
import { UpsertConfigDto } from '../../dto/config/upsert-config.dto';
@Controller('admin/configs')
export class AdminConfigsController {
constructor(private readonly configsService: ConfigsService) {}
@Post('upsert')
async upsertConfig(@Body() data: UpsertConfigDto) {
return await this.configsService.upsertConfig(data);
}
@Get(':key')
async getConfig(@Param('key') key: Config['key_name']) {
return await this.configsService.getConfigRes(key);
}
}

View File

@ -1,3 +1,4 @@
import AppResponse from '@/response/app-response';
import {
Body,
Controller,
@ -5,6 +6,7 @@ import {
Param,
Post,
Put,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
@ -12,7 +14,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { FileInterceptor } from '@nestjs/platform-express';
import { plainToClass } from 'class-transformer';
import { memoryStorage } from 'multer';
import { ClientInfoUpdateBidDto } from '../../dto/bid/client-info-update-bid.dto';
import { ClientUpdateBidDto } from '../../dto/bid/client-update-bid.dto';
import { ClientUpdateLoginStatusDto } from '../../dto/bid/client-update-login-status.dto';
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { UpdateStatusByPriceDto } from '../../dto/bid/update-status-by-price.dto';
import { Bid } from '../../entities/bid.entity';
@ -20,8 +24,6 @@ import { WebBid } from '../../entities/wed-bid.entity';
import { BidsService } from '../../services/bids.service';
import { WebBidsService } from '../../services/web-bids.service';
import { Event } from '../../utils/events';
import AppResponse from '@/response/app-response';
import { ClientUpdateLoginStatusDto } from '../../dto/bid/client-update-login-status.dto';
@Controller('bids')
export class BidsController {
@ -36,9 +38,14 @@ export class BidsController {
return this.bidsService.clientIndex();
}
@Get(':model')
getByModel(@Param('model') model: Bid['model']) {
return this.bidsService.getBidByModel(model);
}
@Post()
create(@Body() data: CreateBidDto) {
return this.bidsService.create(data);
return this.bidsService.create(data, (result) => !!result);
}
@Put(':id')
@ -46,6 +53,11 @@ export class BidsController {
return this.bidsService.clientUpdate(id, data);
}
@Put('info/:id')
updateInfo(@Param('id') id: Bid['id'], @Body() data: ClientInfoUpdateBidDto) {
return this.bidsService.update(id, data);
}
@Post('out-bid/:id')
outBidStatus(@Param('id') id: Bid['id']) {
return this.bidsService.outBid(id);
@ -69,11 +81,18 @@ export class BidsController {
return this.bidsService.updateStatusWork(id, type, image);
}
@Post('update-login-status')
async updateLoginStatus(
@Body() data: ClientUpdateLoginStatusDto
@Post('upload-record/:id')
@UseInterceptors(FileInterceptor('video', { storage: memoryStorage() }))
async uploadRecord(
@Param('id') id: Bid['id'],
@UploadedFile() video: Express.Multer.File,
) {
return await this.bidsService.emitLoginStatus(data)
return this.bidsService.uploadRecord(id, video);
}
@Post('update-login-status')
async updateLoginStatus(@Body() data: ClientUpdateLoginStatusDto) {
return await this.bidsService.emitLoginStatus(data);
}
@Post('test')
@ -92,4 +111,13 @@ export class BidsController {
return AppResponse.toResponse({ code });
}
@Post('hook-action')
async hookAction(
@Body() data: any,
@Query('id') id: Bid['id'],
@Query('type') type: 'action' | 'api',
) {
return this.bidsService.hookAction({ id, type }, data);
}
}

View File

@ -0,0 +1,19 @@
import { IsArray, IsNumber, IsOptional } from 'class-validator';
export class ClientInfoUpdateBidDto {
@IsNumber()
@IsOptional()
max_price: number;
@IsNumber()
@IsOptional()
quantity: number;
@IsNumber()
@IsOptional()
plus_price: number;
@IsArray()
@IsOptional()
metadata: Record<string, any>[];
}

View File

@ -1,4 +1,4 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsNumber, IsObject, IsOptional, IsString } from 'class-validator';
export class ClientUpdateBidDto {
@IsString()
@ -24,4 +24,8 @@ export class ClientUpdateBidDto {
@IsNumber()
@IsOptional()
reserve_price: number;
@IsObject()
@IsOptional()
metadata: Record<string, any>;
}

View File

@ -1,5 +1,6 @@
import { Optional } from '@nestjs/common';
import {
IsArray,
IsBoolean,
IsNumber,
IsOptional,
@ -21,4 +22,8 @@ export class CreateBidDto {
@IsNumber()
@IsOptional()
plus_price: number;
@IsArray()
@IsOptional()
metadata: Record<string, any>[];
}

View File

@ -1,4 +1,4 @@
import { IsNumber, IsOptional } from 'class-validator';
import { IsArray, IsNumber, IsOptional } from 'class-validator';
export class UpdateBidDto {
@IsNumber()
@ -12,4 +12,8 @@ export class UpdateBidDto {
@IsNumber()
@IsOptional()
plus_price: number;
@IsArray()
@IsOptional()
metadata: Record<string, any>[];
}

View File

@ -0,0 +1,13 @@
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpsertConfigDto {
@IsString()
key_name: string;
@IsString()
@IsOptional()
value: string;
@IsEnum(['string', 'number'])
type: 'string' | 'number';
}

View File

@ -1,4 +1,4 @@
import { IsNumber, IsString, IsUrl } from 'class-validator';
import { IsNumber, IsOptional, IsString, IsUrl, Min } from 'class-validator';
export class CreateWebBidDto {
@IsUrl()
@ -6,4 +6,14 @@ export class CreateWebBidDto {
@IsUrl()
url: string;
@IsNumber()
@Min(60)
@IsOptional()
arrival_offset_seconds: number;
@IsNumber()
@Min(600)
@IsOptional()
early_tracking_seconds: number;
}

View File

@ -0,0 +1,67 @@
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Bid } from './bid.entity';
import { Timestamp } from './timestamp';
import { WebBid } from './wed-bid.entity';
@Entity('bid_metadata')
@Unique(['key_name', 'bid'])
export class BidMetadata extends Timestamp {
// value 'live' | 'sandbox'
public static MODE_KEY = 'mode_key';
public static DEMO_RESPONSE = 'demo_response';
public static LATEST_RECROD_LINK = 'lastest_record_link';
// value is minutes esg: arrival_offset_seconds of web bid parent
public static ARRIAVAL_OFFSET_SECONDS_LIVE = 'arrival_offset_seconds_live';
public static ARRIAVAL_OFFSET_SECONDS_SANDBOX =
'arrival_offset_seconds_sandbox';
// value is minutes esg: early_tracking_seconds of web bid parent
public static EARLY_TRACKING_SECONDS_LIVE = 'early_tracking_seconds_live';
public static EARLY_TRACKING_SECONDS_SANDBOX =
'early_tracking_seconds_sandbox';
public static DEFAULT_META_DATA = (webBid: WebBid) => {
return [
{
key_name: BidMetadata.MODE_KEY,
value: JSON.stringify('live'),
},
{
key_name: BidMetadata.ARRIAVAL_OFFSET_SECONDS_LIVE,
value: String(webBid.arrival_offset_seconds),
},
{
key_name: BidMetadata.EARLY_TRACKING_SECONDS_LIVE,
value: String(webBid.early_tracking_seconds),
},
{
key_name: BidMetadata.ARRIAVAL_OFFSET_SECONDS_SANDBOX,
value: String(webBid.arrival_offset_seconds),
},
{
key_name: BidMetadata.EARLY_TRACKING_SECONDS_SANDBOX,
value: String(webBid.early_tracking_seconds),
},
] as BidMetadata[];
};
@PrimaryGeneratedColumn('increment')
id: number;
@Column()
key_name: string;
@Column({ type: 'json' })
value: string;
@ManyToOne(() => Bid, (bid) => bid.metadata, { onDelete: 'CASCADE' })
bid: Bid;
}

View File

@ -9,6 +9,7 @@ import { Timestamp } from './timestamp';
import { BidHistory } from './bid-history.entity';
import { WebBid } from './wed-bid.entity';
import { SendMessageHistory } from './send-message-histories.entity';
import { BidMetadata } from './bid-metadata.entity';
@Entity('bids')
export class Bid extends Timestamp {
@ -45,6 +46,9 @@ export class Bid extends Timestamp {
@Column({ default: null, nullable: true })
close_time: string;
@Column({ default: null, nullable: true })
close_time_ts: Date | null;
@Column({ default: null, nullable: true })
start_bid_time: string;
@ -66,4 +70,7 @@ export class Bid extends Timestamp {
@ManyToOne(() => WebBid, (web) => web.children, { onDelete: 'CASCADE' })
web_bid: WebBid;
@OneToMany(() => BidMetadata, (metadata) => metadata.bid, { cascade: true })
metadata: BidMetadata[];
}

View File

@ -0,0 +1,17 @@
import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { Timestamp } from './timestamp';
@Entity('configs')
export class Config extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true })
key_name: string;
@Column({ nullable: true, default: true })
value: string | null;
@Column()
type: 'string' | 'number';
}

View File

@ -1,8 +1,16 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Timestamp } from './timestamp';
import { Bid } from './bid.entity';
@Entity('send_message_histories')
@Unique(['max_price', 'type', 'reserve_price', 'bid'])
export class SendMessageHistory extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@ -17,4 +25,10 @@ export class SendMessageHistory extends Timestamp {
onDelete: 'CASCADE',
})
bid: Bid;
@Column({ default: 0, nullable: true })
max_price: number;
@Column({ default: 0, nullable: true })
reserve_price: number;
}

View File

@ -1,7 +1,14 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Timestamp } from './timestamp';
import { Bid } from './bid.entity';
import { Exclude } from 'class-transformer';
import { ScrapConfig } from '@/modules/scraps/entities/scrap-config.entity';
@Entity('web_bids')
export class WebBid extends Timestamp {
@ -37,4 +44,7 @@ export class WebBid extends Timestamp {
cascade: true,
})
children: Bid[];
@OneToOne(() => ScrapConfig, (scrap) => scrap.web_bid)
scrap_config: ScrapConfig;
}

View File

@ -21,10 +21,14 @@ export class ImageCompressionPipe implements PipeTransform {
throw new BadRequestException('No file provided');
}
const isVideo = file.mimetype === 'video/mp4';
const ext = path.extname(file.originalname).toLowerCase();
if (!ALLOWED_IMAGE_EXT.includes(ext)) {
const isImage = ALLOWED_IMAGE_EXT.includes(ext);
if (!isImage && !isVideo) {
throw new BadRequestException(
`File "${file.originalname}" is not a valid image.`,
`File "${file.originalname}" is not a valid image or video.`,
);
}
@ -36,16 +40,37 @@ export class ImageCompressionPipe implements PipeTransform {
const files = readdirSync(uploadDir);
files.forEach((file) => {
const filePath = path.join(uploadDir, file);
if (/\.(jpg|jpeg|png|webp)$/i.test(file)) {
if (/\.(jpg|jpeg|png|webp|mp4)$/i.test(file)) {
unlinkSync(filePath);
}
});
}
}
const filename = this.options.unique_name
let filename = null;
if (isVideo) {
filename = this.options.unique_name
? `${Date.now()}-${file.originalname}`
: file.originalname;
const filePath = path.join(uploadDir, filename);
// Ghi trực tiếp buffer ra file
await new Promise<void>((resolve, reject) => {
const fs = require('fs');
fs.writeFile(filePath, file.buffer, (err) => {
if (err) reject(err);
else resolve();
});
});
return { filename };
}
filename = this.options.unique_name
? `${Date.now()}-${this.resize.width || Constant.DEFAULT_RESIZE}x${this.resize.height || Constant.DEFAULT_RESIZE}-${file.originalname.replace(ext, '.webp')}`
: file.originalname.replace(ext, '.webp');
const filePath = path.join(uploadDir, filename);
try {

View File

@ -16,6 +16,8 @@ import { SendMessageHistoriesService } from './send-message-histories.service';
import { NotificationService } from '@/modules/notification/notification.service';
import { isTimeReached } from '@/ultils';
import { BidsService } from './bids.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Event } from '../utils/events';
@Injectable()
export class BidHistoriesService {
@ -28,6 +30,7 @@ export class BidHistoriesService {
readonly sendMessageHistoriesService: SendMessageHistoriesService,
private readonly notificationService: NotificationService,
private readonly bidsService: BidsService,
private eventEmitter: EventEmitter2,
) {}
async index() {
@ -38,6 +41,7 @@ export class BidHistoriesService {
// Tìm thông tin bid từ database
const bid = await this.bidsService.bidsRepo.findOne({
where: { id: bid_id },
relations: { web_bid: true },
});
// Nếu không tìm thấy bid, trả về lỗi 404
@ -102,7 +106,10 @@ export class BidHistoriesService {
// Gửi thông tin bid đến bot telegram
const botData = { ...bid, histories: response };
this.botTelegramApi.sendBidInfo(botData);
// this.botTelegramApi.sendBidInfo(botData);
// Send event thống place bid
this.eventEmitter.emit(Event.BID_SUBMITED, botData);
// Lưu message đã gửi để theo dõi
this.sendMessageHistoriesService.sendMessageRepo.save({

View File

@ -0,0 +1,131 @@
import {
BadRequestException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BidMetadata } from '../entities/bid-metadata.entity';
import { Bid } from '../entities/bid.entity';
import AppResponse from '@/response/app-response';
import { plainToClass } from 'class-transformer';
@Injectable()
export class BidMetadatasService {
constructor(
@InjectRepository(BidMetadata)
readonly bidMetadataRepo: Repository<BidMetadata>,
) {}
async upsert(data: Record<string, any>, bid: Bid) {
const existingMetadata = await this.bidMetadataRepo.find({
where: { bid: { id: bid.id } },
});
const existingMap = new Map(
existingMetadata.map((item) => [item.key_name, item]),
);
const toSave: BidMetadata[] = [];
for (const [key, value] of Object.entries(data)) {
const existing = existingMap.get(key);
if (existing) {
existing.value = value;
toSave.push(existing);
} else {
toSave.push(
this.bidMetadataRepo.create({
key_name: key,
value,
bid: { id: bid.id },
}),
);
}
}
await this.bidMetadataRepo.save(toSave);
return true;
}
async update(id: BidMetadata['id'], value: string) {
const result = await this.bidMetadataRepo.update(id, { value: value });
if (!result) throw new BadRequestException(AppResponse.toResponse(null));
const data = await this.bidMetadataRepo.findOne({ where: { id } });
return AppResponse.toResponse(plainToClass(BidMetadata, data));
}
getMode = (metadatas: BidMetadata[]) => {
const result = metadatas.find(
(item) => item.key_name === BidMetadata.MODE_KEY,
);
return result ? result.value : null;
};
getDemoResponse = (metadatas: BidMetadata[]) => {
const result = metadatas.find(
(item) => item.key_name === BidMetadata.DEMO_RESPONSE,
);
return result ? result.value : null;
};
getLatestRecordLink = (metadatas: BidMetadata[]) => {
const result = metadatas.find(
(item) => item.key_name === BidMetadata.LATEST_RECROD_LINK,
);
return result ? result.value : null;
};
getArrivalOffsetSecondsSandbox = (metadatas: BidMetadata[]) => {
const result = metadatas.find(
(item) => item.key_name === BidMetadata.ARRIAVAL_OFFSET_SECONDS_SANDBOX,
);
return result ? Number(result.value) : null;
};
getArrivalOffsetSecondsLive = (metadatas: BidMetadata[]) => {
const result = metadatas.find(
(item) => item.key_name === BidMetadata.ARRIAVAL_OFFSET_SECONDS_LIVE,
);
return result ? Number(result.value) : null;
};
getArrivalOffsetSecondsByMode = (metadatas: BidMetadata[]) => {
const mode = this.getMode(metadatas) || 'live';
if (mode === 'live') {
return this.getArrivalOffsetSecondsLive(metadatas);
}
return this.getArrivalOffsetSecondsSandbox(metadatas);
};
isSandbox(metadatas: BidMetadata[]) {
return this.getMode(metadatas) !== 'live';
}
async delete(id: Bid['id']) {
const bid = await this.bidMetadataRepo.findOne({ where: { id } });
if (!bid)
throw new NotFoundException(
AppResponse.toResponse(false, {
message: 'Meta is not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
await this.bidMetadataRepo.delete({ id: bid.id });
return AppResponse.toResponse(true, { message: 'Delete success !' });
}
}

View File

@ -1,3 +1,4 @@
import { NotificationService } from '@/modules/notification/notification.service';
import {
BadRequestException,
HttpStatus,
@ -8,7 +9,10 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { plainToClass } from 'class-transformer';
import { Response } from 'express';
import * as fs from 'fs';
import { existsSync, readdirSync } from 'fs';
import * as _ from 'lodash';
import { v4 as uuid } from 'uuid';
import {
FilterOperator,
FilterSuffix,
@ -18,21 +22,27 @@ import {
import { Column } from 'nestjs-paginate/lib/helper';
import { join } from 'path';
import AppResponse from 'src/response/app-response';
import { extractModelId, isTimeReached, subtractMinutes } from 'src/ultils';
import { In, Repository } from 'typeorm';
import {
extractModelId,
isTimePassedByMinutes,
isTimeReached,
parseVideoFileName,
subtractMinutes,
} from 'src/ultils';
import { In, IsNull, Not, Repository } from 'typeorm';
import { ClientUpdateBidDto } from '../dto/bid/client-update-bid.dto';
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
import { CreateBidDto } from '../dto/bid/create-bid.dto';
import { UpdateBidDto } from '../dto/bid/update-bid.dto';
import { UpdateStatusByPriceDto } from '../dto/bid/update-status-by-price.dto';
import { BidHistory } from '../entities/bid-history.entity';
import { BidMetadata } from '../entities/bid-metadata.entity';
import { Bid } from '../entities/bid.entity';
import { ImageCompressionPipe } from '../pipes/image-compression-pipe';
import { Constant } from '../utils/constant';
import { WebBidsService } from './web-bids.service';
import { NotificationService } from '@/modules/notification/notification.service';
import { Event } from '../utils/events';
import _ from 'lodash';
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
import { BidMetadatasService } from './bid-metadatas.service';
import { WebBidsService } from './web-bids.service';
@Injectable()
export class BidsService {
@ -42,8 +52,9 @@ export class BidsService {
@InjectRepository(BidHistory)
readonly bidHistoriesRepo: Repository<BidHistory>,
private readonly webBidsService: WebBidsService,
private eventEmitter: EventEmitter2,
private notificationService: NotificationService,
private readonly eventEmitter: EventEmitter2,
private readonly notificationService: NotificationService,
private readonly bidMetadatasService: BidMetadatasService,
) {}
async index(query: PaginateQuery) {
@ -57,7 +68,7 @@ export class BidsService {
lot_id: true,
close_time: true,
name: [FilterOperator.ILIKE],
status: true
status: true,
};
query.filter = AppResponse.processFilters(query.filter, filterableColumns);
@ -66,6 +77,7 @@ export class BidsService {
sortableColumns: [
'id',
'close_time',
'close_time_ts',
'first_bid',
'model',
'lot_id',
@ -81,6 +93,7 @@ export class BidsService {
relations: {
histories: true,
web_bid: true,
metadata: true,
},
});
@ -100,7 +113,7 @@ export class BidsService {
await this.webBidsService.emitAllBidEvent();
}
async create(data: CreateBidDto) {
async create(data: CreateBidDto, response?: (result: Bid) => any) {
const model = extractModelId(data.url);
const prev = await this.bidsRepo.findOne({ where: { model: model } });
@ -113,19 +126,78 @@ export class BidsService {
const webBid = await this.webBidsService.createByUrl(data.url);
let metadata = BidMetadata.DEFAULT_META_DATA(webBid);
if (data.metadata) {
metadata = metadata.map((item) => {
const reqData = data.metadata.find(
(i) => i?.key_name === item.key_name,
);
if (!reqData) return { ...item };
return {
...item,
value:
item.key_name === BidMetadata.MODE_KEY
? JSON.stringify(reqData.value)
: reqData.value <= 0
? item.value
: String(reqData.value),
};
});
}
const result = await this.bidsRepo.save({
...data,
model,
web_bid: webBid,
metadata,
});
await this.emitAllBidEvent();
return AppResponse.toResponse(plainToClass(Bid, result));
const warnings = [];
if (!webBid.username || !webBid.password) {
// Add warning message
warnings.push(
`Account setup for ${webBid.origin_url} website is not yet complete.`,
);
}
async update(id: Bid['id'], data: UpdateBidDto) {
const prev = await this.bidsRepo.findOne({ where: { id } });
if (!webBid.active) {
// Add warning message
warnings.push(
`${webBid.origin_url} is disabled. Please enable to continue.`,
);
}
if (warnings.length) {
// Send event warning
this.eventEmitter.emit(Event.SEND_WARNING, {
title: `System Warning: Abnormal Data Detected`,
messages: warnings,
});
}
// Send event success
this.eventEmitter.emit(Event.SEND_SUCCESS, {
title: 'Successfully Added Product',
messages: [`Successfully added product to bid list: ${data.url}`],
});
return AppResponse.toResponse(
response ? response(result) : plainToClass(Bid, result),
);
}
async update(id: Bid['id'], { metadata = [], ...data }: UpdateBidDto) {
const prev = await this.bidsRepo.findOne({
where: { id },
relations: { web_bid: true, metadata: true },
});
if (!prev) {
throw new NotFoundException(
AppResponse.toResponse(false, {
@ -135,13 +207,46 @@ export class BidsService {
);
}
const result = await this.bidsRepo.update(id, {
if (metadata) {
await this.bidMetadatasService.bidMetadataRepo.upsert(
metadata.map((item) => {
return { ...item, bid: { id }, value: JSON.stringify(item.value) };
}),
['id', 'bid', 'key_name'],
);
}
let result = null;
if (!prev.close_time) {
// Trường hợp chưa có close_time => update đơn giản
result = await this.bidsRepo.update(id, {
...data,
// status:
// prev.max_price + prev.plus_price > data.max_price
// ? 'out-bid'
// : prev.status,
});
} else {
// Trường hợp đã có close_time => kiểm tra arrival_offset_seconds có thay đổi không
const arrival_offset_seconds =
this.bidMetadatasService.getArrivalOffsetSecondsByMode(
metadata as BidMetadata[],
);
const prev_arrival_offset_seconds =
this.bidMetadatasService.getArrivalOffsetSecondsByMode(prev.metadata);
if (arrival_offset_seconds !== prev_arrival_offset_seconds) {
// Nếu offset thay đổi thì cần cập nhật lại start_bid_time
const start_bid_time = arrival_offset_seconds
? subtractMinutes(prev.close_time, arrival_offset_seconds / 60)
: prev.start_bid_time;
result = await this.bidsRepo.update(id, {
...data,
start_bid_time,
});
} else {
result = await this.bidsRepo.update(id, {
...data,
});
}
}
if (!result) throw new BadRequestException(false);
@ -150,8 +255,23 @@ export class BidsService {
return AppResponse.toResponse(true);
}
async getBidForClientUpdate(id: Bid['id']) {
return await this.bidsRepo.findOne({
where: { id },
relations: { histories: true, web_bid: true, metadata: true },
order: {
histories: {
price: 'DESC',
},
},
});
}
async toggle(id: Bid['id']) {
const bid = await this.bidsRepo.findOne({ where: { id } });
const bid = await this.bidsRepo.findOne({
where: { id },
relations: { web_bid: true },
});
if (!bid) {
throw new NotFoundException(
@ -201,20 +321,49 @@ export class BidsService {
return AppResponse.toResponse(true);
}
/**
* Workflow
* START
* |
* |--> Tìm bid theo id --> Không ? --> Throw 404
* |
* |--> Nếu chưa thời gian bắt đu --> Tính offset --> Gán start_time
* |
* |--> Nếu đã hết giờ:
* | |--> Nếu outbid --> Gán status 'out-bid'
* | |--> Else --> Gán status 'win-bid'
* |
* |--> Nếu chưa hết giờ:
* | |--> Nếu vượt giới hạn --> Gán status 'out-bid'
* |
* |--> Nếu close_time mới > --> cập nhật
* |--> Nếu model mới chưa model --> gán
* |
* |--> Gọi `save(...)` đ lưu lại DB
* |--> Nếu metadata --> gọi `upsert`
* |
* |--> Gửi sự kiện emitAllBidEvent
* |--> Nếu status out-bid hoặc win-bid --> gửi notification
* |
* * |--> Trả response
* END
*/
async clientUpdate(
id: Bid['id'],
{ close_time, model, ...data }: ClientUpdateBidDto, // Nhận dữ liệu cập nhật
{ close_time, model, metadata, ...data }: ClientUpdateBidDto, // Nhận dữ liệu cập nhật
) {
// Tìm kiếm phiên đấu giá trong database theo id
const bid = await this.bidsRepo.findOne({
where: { id },
relations: { histories: true, web_bid: true },
order: {
histories: {
price: 'DESC',
},
},
});
// // Tìm kiếm phiên đấu giá trong database theo id
// let bid = await this.bidsRepo.findOne({
// where: { id },
// relations: { histories: true, web_bid: true, metadata: true },
// order: {
// histories: {
// price: 'DESC',
// },
// },
// });
let bid = await this.getBidForClientUpdate(id);
// Nếu không tìm thấy phiên đấu giá, trả về lỗi 404
if (!bid)
@ -226,13 +375,41 @@ export class BidsService {
);
// Nếu phiên đấu giá chưa có thời gian bắt đầu và kết thúc
if (!bid.close_time && !bid.start_bid_time) {
if (!bid.close_time && !bid.start_bid_time && data?.lot_id) {
// Tách lấy arrival_offset_seconds trong product nếu không có lấy trong web bid.
const arrival_offset_seconds =
this.bidMetadatasService.getArrivalOffsetSecondsByMode(bid.metadata) ||
bid.web_bid.arrival_offset_seconds;
// Thiết lập thời gian bắt đầu là 5 phút trước khi đóng
// bid.start_bid_time = new Date().toUTCString();
bid.start_bid_time = subtractMinutes(
close_time,
bid.web_bid.arrival_offset_seconds / 60,
arrival_offset_seconds / 60,
);
// update
await this.bidsRepo.update(
{ id },
{ start_bid_time: bid.start_bid_time },
);
}
// Cập nhật thời gian kết thúc đấu giá nếu `close_time` mới lớn hơn `close_time` cũ
if (
close_time &&
new Date(close_time).getTime() > new Date(bid.close_time).getTime()
) {
bid.close_time = close_time;
bid.close_time_ts = new Date(close_time);
// update
await this.bidsRepo.update(
{ id },
{ close_time: bid.close_time, close_time_ts: bid.close_time_ts },
);
bid = await this.getBidForClientUpdate(id);
}
// Kiểm tra nếu thời gian đóng bid đã đạt tới (tức phiên đấu giá đã kết thúc)
@ -251,7 +428,6 @@ export class BidsService {
}
} else {
// Nếu phiên đấu giá vẫn đang diễn ra và giá hiện tại vượt quá giới hạn đặt của người dùng
if (
data.current_price > bid.max_price + bid.plus_price ||
(!bid.histories.length &&
@ -261,13 +437,14 @@ export class BidsService {
}
}
// Cập nhật thời gian kết thúc đấu giá nếu `close_time` mới lớn hơn `close_time` cũ
if (
close_time &&
new Date(close_time).getTime() > new Date(bid.close_time).getTime()
) {
bid.close_time = close_time;
}
// // Cập nhật thời gian kết thúc đấu giá nếu `close_time` mới lớn hơn `close_time` cũ
// if (
// close_time &&
// new Date(close_time).getTime() > new Date(bid.close_time).getTime()
// ) {
// bid.close_time = close_time;
// bid.close_time_ts = new Date(close_time);
// }
// Nếu chưa có `model` nhưng dữ liệu mới có model, thì cập nhật model
if (model && !bid.model) {
@ -282,6 +459,10 @@ export class BidsService {
updated_at: new Date(), // Cập nhật timestamp
});
if (metadata) {
await this.bidMetadatasService.upsert(metadata, bid);
}
// Phát sự kiện cập nhật toàn bộ danh sách đấu giá
this.emitAllBidEvent();
@ -291,6 +472,30 @@ export class BidsService {
this.notificationService.emitBidStatus(result);
}
// Send INFO
if (bid.current_price < result.current_price && bid.histories.length) {
this.eventEmitter.emit(Event.SEND_INFO, {
title: 'New Higher Bid Detected',
messages: [
`Another user just placed a higher bid of ${result.current_price} on <a href="${bid.url}">${bid.name}</a>.`,
],
});
}
// Send error when bidding fail
if (
isTimePassedByMinutes(result.start_bid_time, 1) &&
!bid.histories.length &&
!this.bidMetadatasService.isSandbox(bid.metadata)
) {
this.eventEmitter.emit(Event.SEND_ERROR, {
title: 'Bidding Error Detected',
messages: [
`An error occurred while placing a bid on <a href="${bid.url}">${bid.name}</a>. Please check the system.`,
],
});
}
// Trả về kết quả cập nhật dưới dạng response chuẩn
return AppResponse.toResponse(plainToClass(Bid, result));
}
@ -298,7 +503,10 @@ export class BidsService {
async outBid(id: Bid['id']) {
const result = await this.bidsRepo.update(id, { status: 'out-bid' });
const bid = await this.bidsRepo.findOne({ where: { id } });
const bid = await this.bidsRepo.findOne({
where: { id },
relations: { web_bid: true },
});
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
@ -350,7 +558,10 @@ export class BidsService {
}
async updateStatusByPrice(id: Bid['id'], data: UpdateStatusByPriceDto) {
const bid = await this.bidsRepo.findOne({ where: { id } });
const bid = await this.bidsRepo.findOne({
where: { id },
relations: { web_bid: true },
});
if (!bid)
throw new NotFoundException(
@ -441,6 +652,34 @@ export class BidsService {
return AppResponse.toResponse(true);
}
async uploadRecord(id: Bid['id'], video: Express.Multer.File) {
if (!video) {
throw new BadRequestException(
AppResponse.toResponse(null, { message: 'File or Url is required' }),
);
}
const data: { filename: string } | undefined =
await new ImageCompressionPipe(
`${Constant.RECORD_FOLDER}`,
{},
{
unique_image_folder: false,
unique_name: false,
},
).transform(video);
if (!data)
throw new BadRequestException(
AppResponse.toResponse(null, {
message: "Can't create media",
status_code: HttpStatus.BAD_REQUEST,
}),
);
return AppResponse.toResponse(true);
}
async getStatusWorkingImage(
id: Bid['id'],
type: string,
@ -497,6 +736,84 @@ export class BidsService {
return res.sendFile(filePath);
}
async getRecord(name: string, res: Response) {
const rootDir = process.cwd();
const filePath = join(
rootDir,
`${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}/${name}`,
);
if (!existsSync(filePath)) {
throw new NotFoundException(
AppResponse.toResponse(null, {
message: 'Folder not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
}
const stat = fs.statSync(filePath);
const fileSize = stat.size;
res.writeHead(200, {
'Content-Type': 'video/mp4',
'Content-Length': fileSize,
});
const readStream = fs.createReadStream(filePath);
readStream.pipe(res);
}
async getRecords(id: Bid['id']) {
const rootDir = process.cwd();
const folderPath = join(
rootDir,
`${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}`,
);
if (!existsSync(folderPath)) {
throw new NotFoundException(
AppResponse.toResponse(null, {
message: 'Folder not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
}
const files = await fs.promises.readdir(folderPath);
const data = files
.map((item) => {
return parseVideoFileName(item);
})
.filter((i) => i.bid_id == id);
const sorted = _.orderBy(data, ['timestamp'], ['desc']);
return AppResponse.toResponse(sorted);
}
async deleteRecord(name: string) {
const rootDir = process.cwd();
const filePath = join(
rootDir,
`${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}/${name}`,
);
if (!existsSync(filePath)) {
throw new NotFoundException(
AppResponse.toResponse(null, {
message: 'File not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
}
await fs.promises.unlink(filePath);
return AppResponse.toResponse(true);
}
async getImagesWorking(id: Bid['id'], type: string) {
const rootDir = process.cwd();
const folderPath = join(
@ -524,4 +841,101 @@ export class BidsService {
return AppResponse.toResponse(true);
}
async getNextBid(): Promise<Bid | null> {
const all = await this.bidsRepo.find({
where: { status: 'biding', close_time: Not(IsNull()) },
relations: { web_bid: true },
});
const now = Date.now();
let nextBid = null;
let minDiff = Infinity;
for (const bid of all) {
const time = Date.parse(bid.close_time);
if (!isNaN(time) && time >= now) {
const diff = time - now;
if (diff < minDiff) {
minDiff = diff;
nextBid = bid;
}
}
}
return nextBid;
}
async getBidByModel(model: string) {
const bid = await this.bidsRepo.findOne({
where: { model },
relations: { metadata: true, web_bid: true },
select: {
web_bid: { arrival_offset_seconds: true, early_tracking_seconds: true },
},
});
if (!bid)
return AppResponse.toResponse(null, {
status_code: HttpStatus.NOT_FOUND,
});
return AppResponse.toResponse(plainToClass(Bid, bid));
}
async hookAction(
{ id, type }: { id: Bid['id']; type: 'action' | 'api' },
data: any,
) {
const bid = await this.bidsRepo.findOne({
where: { id },
relations: { metadata: true },
});
if (!bid)
throw new NotFoundException(
AppResponse.toResponse(null, { message: 'Not foud bid' }),
);
if (type === 'api') {
const result = JSON.parse(data?.data || {});
result['timestamp'] = new Date().getTime();
result['uuid'] = uuid();
const prevDemoResponse = this.bidMetadatasService.getDemoResponse(
bid.metadata,
);
console.log({ prevDemoResponse, result });
await this.bidMetadatasService.upsert(
{
[BidMetadata.DEMO_RESPONSE]:
JSON.stringify([result, ...(prevDemoResponse || [])]) ||
JSON.stringify([]),
},
bid,
);
} else {
const recordUrl = data?.record_url || '';
await this.bidMetadatasService.upsert(
{
[BidMetadata.LATEST_RECROD_LINK]: JSON.stringify(recordUrl),
},
bid,
);
}
const latestBidData = await this.bidsRepo.findOne({
where: { id },
relations: { metadata: true },
});
this.eventEmitter.emit(Event.BID_DEMO, { bid: latestBidData, type });
return AppResponse.toResponse(data);
}
}

View File

@ -0,0 +1,75 @@
import {
BadRequestException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Config } from '../entities/configs.entity';
import AppResponse from '@/response/app-response';
@Injectable()
export class ConfigsService {
public static CONFIG_KEYS = {
REFRESH_TOOL_TIME: 'REFRESH_TOOL_TIME',
MAIL_SCRAP_REPORT: 'MAIL_SCRAP_REPORT',
};
constructor(
@InjectRepository(Config)
readonly configRepo: Repository<Config>,
) {}
async getConfig(key_name: keyof typeof ConfigsService.CONFIG_KEYS) {
return (await this.configRepo.findOne({ where: { key_name } })) || null;
}
async setConfig(
key_name: keyof typeof ConfigsService.CONFIG_KEYS,
value: string,
type: 'string' | 'number',
) {
return await this.configRepo.upsert({ key_name, value, type }, [
'key_name',
]);
}
async getConfigRes(key_name: string) {
const result = await this.getConfig(
key_name as keyof typeof ConfigsService.CONFIG_KEYS,
);
if (!result)
throw new NotFoundException(
AppResponse.toResponse(null, {
message: 'Config key name not found',
status_code: HttpStatus.NOT_FOUND,
}),
);
return AppResponse.toResponse(result);
}
async upsertConfig(data: Partial<Config>) {
let response = null;
const prevConfig = await this.configRepo.findOne({
where: { key_name: data.key_name },
});
if (!prevConfig) {
response = await this.configRepo.save(data);
} else {
response = await this.configRepo.update(
{ key_name: data.key_name },
data,
);
}
if (!response) throw new BadRequestException(AppResponse.toResponse(false));
return AppResponse.toResponse(!!response);
}
}

View File

@ -98,7 +98,8 @@ export class ImapService {
const result = verifyCode(emailContent);
if (!result) {
throw new Error('fetchLatestEmail: Name or Code is empty');
console.log('fetchLatestEmail: Name or Code is empty');
return;
}
const { code, name } = result;
@ -108,7 +109,8 @@ export class ImapService {
});
if (!webBid) {
throw new Error('Not found web bid');
console.log('Not found web bid');
return;
}
// send message event

View File

@ -0,0 +1,98 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { IsNull, Not } from 'typeorm';
import { BidsService } from './bids.service';
import * as moment from 'moment';
import {
isTimeReached,
shouldResetTool,
subtractMinutes,
subtractSeconds,
} from '@/ultils';
import { ConfigsService } from './configs.service';
import { DashboardService } from './dashboard.service';
import { Bid } from '../entities/bid.entity';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(
private readonly bidsService: BidsService,
private readonly configsService: ConfigsService,
private readonly dashboadService: DashboardService,
) {}
// - Chạy kiểm tra và lấy thời gian close của product gần với hiện tại nhất
// - Nếu thỏa điều kiện sẽ reset tool trước thời gian close 20 phút
@Cron(CronExpression.EVERY_MINUTE)
async handleResetTool() {
const bids = await this.bidsService.bidsRepo.find({
where: { status: 'biding' },
select: ['close_time', 'created_at', 'start_bid_time', 'id', 'lot_id'],
});
const lastResetTimeRaw =
await this.configsService.getConfig('REFRESH_TOOL_TIME');
const lastResetTime = lastResetTimeRaw?.value
? new Date(lastResetTimeRaw.value)
: null;
const now = new Date();
const minutesReset = 20;
console.log('===== Reset Tool Check =====');
console.log(
'Last reset time:',
lastResetTime
? moment(lastResetTime).format('YYYY-MM-DD HH:mm:ss')
: 'null',
);
console.log('Current time:', moment(now).format('YYYY-MM-DD HH:mm:ss'));
const result = shouldResetTool(bids, lastResetTime, now);
if (result.shouldReset) {
console.log('Should reset due to:', result.reason);
if (result.bidId) {
console.log('Related bid id:', result.bidId);
if (result.closeTime) {
const closeTimeDate = new Date(result.closeTime);
const expectedReset = moment(closeTimeDate)
.subtract(minutesReset, 'minutes')
.format('YYYY-MM-DD HH:mm:ss');
console.log(
`Expected reset time (close_time - ${minutesReset} minutes):`,
expectedReset,
);
}
}
await this.dashboadService.resetTool();
await this.configsService.setConfig(
'REFRESH_TOOL_TIME',
now.toISOString(),
'string',
);
console.log('Tool reset at:', moment(now).format('YYYY-MM-DD HH:mm:ss'));
} else {
console.log('No reset needed at this time');
}
}
// Reset lại server.
// Nếu server quá lâu không được reset sẽ đẫn đến mất kết nối với imap (không nhận được mail)
//Chạy vào 12 giờ đêm mỗi ngày
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleResetServer() {
const processName = 'auto-bid-server';
console.log('===== Reset Server Check =====');
await this.dashboadService.resetProcessByName(processName);
console.log(
`Reset server at: ${moment(new Date()).format('YYYY-MM-DD HH:mm:ss')}`,
);
}
}

View File

@ -53,6 +53,9 @@ export class WebBidsService {
filterableColumns,
defaultSortBy: [['id', 'DESC']],
maxLimit: 100,
relations: {
scrap_config: true,
},
});
return AppResponse.toPagination<WebBid>(data, true, WebBid);
@ -65,7 +68,14 @@ export class WebBidsService {
url: Not(IsNull()),
children: { status: 'biding' },
},
relations: { children: { histories: true, web_bid: true } },
relations: {
children: { histories: true, web_bid: true, metadata: true },
},
order: {
children: {
close_time_ts: 'ASC',
},
},
});
return data;

View File

@ -3,6 +3,7 @@ export class Constant {
public static BOT_TELEGRAM_PATH = 'bot-data';
public static WORK_IMAGES_FOLDER = 'work-images';
public static RECORD_FOLDER = 'records';
public static TMP_FOLDER = 'tmp';
public static DEFAULT_RESIZE = 400;

View File

@ -7,6 +7,13 @@ export class Event {
public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
public static WEB_UPDATED = 'webUpdated';
public static LOGIN_STATUS = 'login-status';
public static BID_SUBMITED = 'bid-submited';
public static BID_STATUS = 'bid-status';
public static BID_DEMO = 'bid-demo';
public static SEND_WARNING = 'send-warning';
public static SEND_SUCCESS = 'send-success';
public static SEND_ERROR = 'send-error';
public static SEND_INFO = 'send-info';
public static verifyCode(data: WebBid) {
return `${this.VERIFY_CODE}.${data.origin_url}`;

View File

@ -0,0 +1,22 @@
import { Constant } from '@/modules/bids/utils/constant';
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { join } from 'path';
@Controller('extensions')
export class ExtensionsController {
@Get('policy')
async policy(@Res() res: Response) {
const rootDir = process.cwd();
const filePath = join(
rootDir,
'src',
'modules',
'extensions',
`/views/policy.html`,
);
return res.sendFile(filePath);
}
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ExtensionsController } from './controllers/extensions.controller';
@Module({
controllers: [ExtensionsController],
})
export class ExtensionsModule {}

View File

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Privacy Policy - Bid Extension</title>
<style>
:root {
--primary-color: #3498db;
--secondary-color: #2c3e50;
--text-color: #333;
--light-bg: #f8f9fa;
--border-color: #e0e0e0;
}
body {
font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: var(--text-color);
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
}
.container {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 30px;
}
h1 {
color: var(--primary-color);
text-align: center;
border-bottom: 2px solid var(--border-color);
padding-bottom: 15px;
margin-bottom: 25px;
}
h2 {
color: var(--secondary-color);
border-left: 4px solid var(--primary-color);
padding-left: 10px;
margin-top: 30px;
}
p {
margin-bottom: 15px;
}
strong {
color: var(--secondary-color);
}
code {
background-color: var(--light-bg);
padding: 2px 5px;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
color: #e74c3c;
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color 0.3s;
}
a:hover {
color: #2980b9;
text-decoration: underline;
}
.effective-date {
background-color: var(--light-bg);
padding: 10px 15px;
border-radius: 5px;
display: inline-block;
margin-bottom: 20px;
}
.contact-section {
background-color: var(--light-bg);
padding: 15px;
border-radius: 5px;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="container">
<h1>Privacy Policy</h1>
<div class="effective-date">
<strong>Effective Date:</strong> 2025-05-20
</div>
<p>This privacy policy explains how the Chrome Extension <strong>Bid Extension</strong> handles user data.</p>
<h2>1. Information Collection</h2>
<p>The Bid Extension does <strong>not</strong> collect or transmit any personal or sensitive user data.</p>
<p>The extension uses the <code>storage</code> permission to save settings and preferences <strong>locally</strong> in your browser.</p>
<h2>2. Local Storage Only</h2>
<p>All data used by this extension is stored using Chrome's local storage (via the <code>chrome.storage</code> API). This data never leaves your device and is not transmitted to any external server.</p>
<h2>3. No Third-party Sharing</h2>
<p>We do not share any user data with third parties. We do not use analytics, ads, or any tracking technologies.</p>
<h2>4. Changes to this Policy</h2>
<p>If we update this privacy policy in the future, we will notify users by updating this document with the new effective date.</p>
<h2>5. Contact</h2>
<div class="contact-section">
<p>If you have any questions about this privacy policy, please contact us at: <a href="mailto:bid@apactech.io">bid@apactech.io</a></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,34 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { forwardRef, Module } from '@nestjs/common';
import { MailsService } from './services/mails.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MailProcessor } from './process/mail.processor';
import { BidsModule } from '../bids/bids.module';
@Module({
imports: [
forwardRef(() => BidsModule),
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
transport: {
host: config.get<string>('MAIL_SERVER'),
port: 465,
secure: true, // true nếu port là 465
auth: {
user: config.get<string>('MAIL_USER'),
pass: config.get<string>('MAIL_PASSWORD'),
},
},
defaults: {
from: `"Bids" <${config.get<string>('MAIL_USER')}>`,
},
}),
inject: [ConfigService],
}),
],
providers: [MailsService, MailProcessor],
exports: [MailsService],
})
export class MailsModule {}

View File

@ -0,0 +1,16 @@
// processors/mail.processor.ts
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { MailsService } from '../services/mails.service';
@Processor('mail-queue')
export class MailProcessor {
constructor(private readonly mailsService: MailsService) {}
@Process('send-mail')
async handleSendMail(job: Job) {
const { to, subject, html } = job.data;
await this.mailsService.sendPlainHtml(to, subject, html);
}
}

View File

@ -0,0 +1,535 @@
import { Bid } from '@/modules/bids/entities/bid.entity';
import { BidMetadatasService } from '@/modules/bids/services/bid-metadatas.service';
import { BidsService } from '@/modules/bids/services/bids.service';
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
import {
extractDomain,
extractDomainSmart,
formatEndTime,
isHotItemFn,
isTimeReached,
} from '@/ultils';
import { MailerService } from '@nestjs-modules/mailer';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import * as _ from 'lodash';
@Injectable()
export class MailsService {
constructor(
private readonly mailerService: MailerService,
@InjectQueue('mail-queue') private mailQueue: Queue,
private readonly bidsService: BidsService,
private readonly bidMetadataService: BidMetadatasService,
) {}
async sendPlainText(to: string, subject: string, content: string) {
await this.mailerService.sendMail({
to,
subject,
text: content,
});
}
async sendHtmlMailJob(mailData: {
to: string;
subject: string;
html: string;
}) {
await this.mailQueue.add('send-mail', mailData);
}
async sendPlainHtml(to: string, subject: string, html: string) {
const emails = to
.split(',')
.map((e) => e.trim())
.filter(Boolean);
const mainRecipient = emails[0];
const ccRecipients = emails.slice(1); // Những email còn lại
await this.mailerService.sendMail({
to: mainRecipient,
cc: ccRecipients.length > 0 ? ccRecipients : undefined,
subject,
html,
});
}
generateProductTableHTML(
products: ScrapItem[],
hotItems: { name: string }[],
): {
html: string;
hasHotItem: boolean;
} {
const from = process.env.MAIL_USER || 'no-reply@example.com';
let hasHotItem = false;
if (!products.length) {
return {
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Product Listing</title>
</head>
<body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
<h2 style="text-align: center; color: #333;">Product Listing</h2>
<p style="text-align: center; color: #666;">No matching products found for your keywords today.</p>
<p style="text-align: center; color: #999; font-size: 12px; margin-top: 40px;">From: ${from}</p>
</body>
</html>
`,
hasHotItem,
};
}
// Decorate products with isHotItem
const decorated = products.map((p) => {
const isHotItem = hotItems.some((obj) =>
p.name.toLowerCase().includes(obj.name.toLowerCase()),
);
if (isHotItem) hasHotItem = true;
return {
...p,
isHotItem,
isNew:
new Date(p.created_at).getTime() === new Date(p.updated_at).getTime(),
};
});
// Separate hot and non-hot items, preserving original order
const hotItemsList = decorated.filter((p) => p.isHotItem);
const nonHotItems = decorated.filter((p) => !p.isHotItem);
// Đưa các item mới (isNew) lên đầu trong nhóm không phải hot item
const sortedNonHotItems = [
...nonHotItems.filter((p) => p.isNew),
...nonHotItems.filter((p) => !p.isNew),
];
// Separate hot and non-hot items, preserving original order
const hotItemsFirst = [...hotItemsList, ...sortedNonHotItems];
const rows = hotItemsFirst
.map((p) => {
const isHotItem = isHotItemFn(p, hotItems);
if (isHotItem) {
hasHotItem = true;
}
const isNew =
new Date(p.created_at).getTime() === new Date(p.updated_at).getTime();
const nameStyle = isNew ? 'color: #2f9e44; font-weight: bold;' : '';
const namePrefix = isHotItem
? '<span style="font-weight: bold; color: #e03131;">[HOT ITEM]</span> '
: '';
return `
<tr ${isHotItem ? 'style="background-color: #ff848496;margin-bottom: 4px;"' : 'margin-bottom: 4px;'}>
<td style="display:flex;max-width: 60px;"><img src="${p.image_url}" alt="Product Image" style="height: 60px !important;width:60px !important; object-fit: contain; border-radius: 4px;" /></td>
<td style="${nameStyle}">${namePrefix}${p.name}</td>
<td style="font-weight: bold; color: #e03131;">${p.current_price ? '$' + p.current_price : 'None'}</td>
<td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
<td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
</tr>
`;
})
.join('');
const title = hasHotItem
? '<span style="font-weight: bold; color: #e03131;">[HOT ITEMS]</span> Product Listing'
: 'Product Listing';
return {
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>${title}</title>
</head>
<body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
<h2 style="text-align: center; color: #333;">${title}</h2>
<div style="overflow-x: auto;">
<table style="width: 100%; min-width: 500px; border-collapse: collapse; background: #fff; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.05);">
<thead style="background: #f1f3f5;">
<tr>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Image</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Name</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Price</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Link</th>
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Domain</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
<p style="text-align: center; color: #999; font-size: 12px; margin-top: 40px;">From: ${from}</p>
</body>
</html>
`,
hasHotItem,
};
}
getAuctionStatusEmailContent(bid: Bid): string {
const webname = extractDomain(bid.web_bid.origin_url);
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
const endTime = formatEndTime(bid.close_time, false);
const competitor = `$${bid.current_price}`;
const max = `$${bid.max_price}`;
const submitted = `$${bid.max_price}`;
const nextBid = bid.max_price + bid.plus_price;
const from = process.env.MAIL_USER || 'no-reply@example.com';
const cardStyle = `
max-width: 600px;
margin: 20px auto;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
color: #333;
padding: 20px;
`;
const headerStyle = (color: string) =>
`font-size: 22px; font-weight: 700; color: ${color}; margin-bottom: 15px;`;
const labelStyle = `font-weight: 600; width: 120px; display: inline-block; color: #555;`;
const valueStyle = `color: #222;`;
const renderRow = (label: string, value: string) =>
`<p><span style="${labelStyle}">${label}:</span> <span style="${valueStyle}">${value}</span></p>`;
switch (bid.status) {
case 'biding':
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#2c7a7b')}"> Auto Bid Started</h2>
${renderRow('Title', title)}
${renderRow('Max', max)}
${renderRow('End time', endTime)}
${renderRow('Competitor', competitor)}
${renderRow('Bid submitted', submitted)}
${renderRow('From', from)}
</div>
`;
case 'out-bid': {
const overLimit = bid.current_price >= nextBid;
const belowReserve = bid.reserve_price > nextBid;
const timeExtended = bid.close_time ? 'Time extended' : 'No extension';
if (isTimeReached(bid.close_time)) {
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#718096')}"> Auction Ended</h2>
${renderRow('Title', title)}
${renderRow('End time', endTime)}
${renderRow('Final price', competitor)}
${renderRow('From', from)}
</div>
`;
}
if (overLimit || belowReserve) {
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#dd6b20')}"> Outbid (${timeExtended})</h2>
${renderRow('Title', title)}
${renderRow('Competitor', competitor)}
${renderRow('Max', max)}
${renderRow('Next bid at', `$${nextBid}`)}
${renderRow('End time', endTime)}
${renderRow('From', from)}
<p style="color:#c05621; font-weight: 600;"> Current bid exceeds your max bid.</p>
</div>
`;
}
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#e53e3e')}">🛑 Auction Canceled (${timeExtended})</h2>
${renderRow('Title', title)}
${renderRow('Competitor', competitor)}
${renderRow('Max', max)}
${renderRow('Next bid at', `$${nextBid}`)}
${renderRow('End time', endTime)}
${renderRow('From', from)}
<p style="color:#9b2c2c; font-weight: 600;">🛑 Auction has been canceled.</p>
</div>
`;
}
case 'win-bid':
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#2b6cb0')}">🎉 You Won!</h2>
${renderRow('Title', title)}
${renderRow('Price won', `$${bid.current_price}`)}
${renderRow('Max', max)}
${renderRow('From', from)}
</div>
`;
default:
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#718096')}"> Unknown Status</h2>
${renderRow('Title', title)}
${renderRow('From', from)}
</div>
`;
}
}
getBidSubmittedEmailContent(bid: Bid): string {
const webname = extractDomain(bid.web_bid.origin_url);
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
const endTime = formatEndTime(bid.close_time, false);
const competitor = `$${bid.current_price}`;
const max = `$${bid.max_price}`;
const submitted = `$${bid.max_price}`;
const maxReached = bid.max_price <= bid.max_price;
const from = process.env.MAIL_USER || 'no-reply@example.com';
return `
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: Arial, sans-serif;
background: #f9f9f9;
color: #333;
padding: 20px;
}
.container {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
max-width: 600px;
margin: auto;
}
h2 {
color: #007bff;
text-align: center;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
th, td {
padding: 10px;
border-bottom: 1px solid #ddd;
text-align: left;
}
th {
background-color: #f1f1f1;
color: #555;
}
.highlight {
color: #e03131;
font-weight: bold;
}
.max-reach {
color: #d6336c;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h2>Bid Submitted${bid.close_time ? ', Time extended' : ', No extension'}${maxReached ? ' <span class="max-reach">* MAX REACH *</span>' : ''}</h2>
<table>
<tbody>
<tr>
<th>Title</th>
<td>${title}</td>
</tr>
<tr>
<th>Competitor</th>
<td>${competitor}</td>
</tr>
<tr>
<th>Bid Submitted</th>
<td>${submitted} ${maxReached ? '<span class="max-reach">(<b>***MAXIMUM REACH***</b>)</span>' : ''}</td>
</tr>
<tr>
<th>Max</th>
<td class="highlight">${max}</td>
</tr>
<tr>
<th>End Time</th>
<td>${endTime}</td>
</tr>
<tr>
<th>From</th>
<td>${from}</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
`;
}
async getBidRecordEmailContent(bid: Bid, type: 'action' | 'api') {
let recordData: {
bid_id: number;
name: string;
original_name: string;
timestamp: number;
}[] = [];
try {
const data = await this.bidsService.getRecords(bid.id);
recordData = data.data || [];
} catch (error) {
recordData = [];
}
const json = this.bidMetadataService.getDemoResponse(bid.metadata);
const videoUrl = this.bidMetadataService.getLatestRecordLink(bid.metadata);
const closeTime = bid.close_time
? new Date(bid.close_time).toLocaleString('en-AU', {
timeZone: 'Australia/Sydney',
dateStyle: 'medium',
timeStyle: 'short',
})
: 'Unknown';
let content = '';
if (type === 'action') {
content = `
<h3>📹 Record Preview</h3>
<p><strong>Name:</strong> ${bid?.name}</p>
<p><strong>Recorded:</strong> ${new Date().toLocaleString()}</p>
<p>
<a href="${videoUrl || ''}" target="_blank" style="
display: inline-block;
padding: 10px 16px;
background-color: #007bff;
color: #fff;
text-decoration: none;
border-radius: 4px;
margin-top: 12px;
">
Watch Video
</a>
</p>
`;
} else if (type === 'api') {
content = `
<h3>📦 Record JSON</h3>
<pre style="
background-color: #f4f4f4;
padding: 12px;
border-radius: 4px;
font-size: 13px;
overflow-x: auto;
">${JSON.stringify(json, null, 2)}</pre>
`;
}
return `
<div style="font-family: Arial, sans-serif; color: #333; max-width: 600px; margin: 0 auto;">
<h2>📝 Bid Demo: ${bid?.name}</h2>
<p><strong> Close Time:</strong> ${closeTime}</p>
${
content
? content
: `<p style="color: red;">⚠️ No record available for this bid.</p>`
}
<hr style="margin: 24px 0;">
<p style="font-size: 12px; color: #888;">This is an automated message. Please do not reply.</p>
</div>
`;
}
generateWarningEmailTemplate(title: string, messages: string[]): string {
const timestamp = new Date().toLocaleString(); // hoặc new Date().toISOString()
return `
<div style="font-family: Arial, sans-serif; padding: 20px; background-color: #fffbe6; color: #333;">
<h2 style="color: #d9534f;"> ${title}</h2>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join('')}
</ul>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
Sent at: ${timestamp}
</p>
</div>
`;
}
generateSuccessEmailTemplate(title: string, messages: string[]): string {
const timestamp = new Date().toLocaleString(); // hoặc new Date().toISOString()
return `
<div style="font-family: Arial, sans-serif; padding: 20px; background-color: #e6fff2; color: #333;">
<h2 style="color: #28a745;"> ${title}</h2>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join('')}
</ul>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
Sent at: ${timestamp}
</p>
</div>
`;
}
generateErrorEmailTemplate(title: string, messages: string[]): string {
const timestamp = new Date().toLocaleString();
return `
<div style="font-family: Arial, sans-serif; padding: 20px; background-color: #fdecea; color: #333;">
<h2 style="color: #c82333;"> ${title}</h2>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join('')}
</ul>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
Sent at: ${timestamp}
</p>
</div>
`;
}
generateInfoEmailTemplate(title: string, messages: string[]): string {
const timestamp = new Date().toLocaleString();
return `
<div style="font-family: Arial, sans-serif; padding: 20px; background-color: #e7f3fe; color: #333;">
<h2 style="color: #31708f;"> ${title}</h2>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join('')}
</ul>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
Sent at: ${timestamp}
</p>
</div>
`;
}
}

View File

@ -1,3 +1,3 @@
export const NAME_EVENTS = {
BID_STATUS: 'notify.bid-status',
};
// export const NAME_EVENTS = {
// BID_STATUS: 'notify.bid-status',
// };

View File

@ -20,7 +20,8 @@ export class ClientNotificationController {
@Post('test')
async test() {
const bid = await this.bidsService.bidsRepo.findOne({
where: { lot_id: '26077023' },
where: { lot_id: '23755862' },
relations: { web_bid: true },
});
return await this.notifyService.emitBidStatus({

View File

@ -1,16 +1,23 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NAME_EVENTS } from '../constants';
import { Bid } from '@/modules/bids/entities/bid.entity';
import { Notification } from '../entities/notification.entity';
import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
import { MailsService } from '@/modules/mails/services/mails.service';
import { ConfigsService } from '@/modules/bids/services/configs.service';
import * as moment from 'moment';
import { Event } from '@/modules/bids/utils/events';
@Injectable()
export class AdminNotificationListener {
constructor(private readonly botTelegramApi: BotTelegramApi) {}
constructor(
private readonly botTelegramApi: BotTelegramApi,
private readonly mailsService: MailsService,
private readonly configsSerice: ConfigsService,
) {}
@OnEvent(NAME_EVENTS.BID_STATUS)
handleBidStatus({
@OnEvent(Event.BID_STATUS)
async handleBidStatus({
bid,
notification,
}: {
@ -19,6 +26,141 @@ export class AdminNotificationListener {
}) {
if (JSON.parse(notification.send_to).length <= 0) return;
this.botTelegramApi.sendMessage(notification.message);
// this.botTelegramApi.sendMessage(notification.message);
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
this.mailsService.sendHtmlMailJob({
to: mails,
html: this.mailsService.getAuctionStatusEmailContent(bid),
subject:
`[${bid.status.toUpperCase()}] Report Auto Auctions System ` +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
@OnEvent(Event.BID_SUBMITED)
async handleBidSubmited(bid: Bid) {
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
this.mailsService.sendHtmlMailJob({
to: mails,
html: this.mailsService.getBidSubmittedEmailContent(bid),
subject:
'Report Auto Auctions System ' +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
@OnEvent(Event.BID_DEMO)
async handleBidDemo({ bid, type }: { bid: Bid; type: 'api' | 'action' }) {
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
const html = await this.mailsService.getBidRecordEmailContent(bid, type);
this.mailsService.sendHtmlMailJob({
to: mails,
html: html,
subject:
'Demo Auto Auctions System ' +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
@OnEvent(Event.SEND_WARNING)
async handleSendWarning({
title,
messages,
}: {
title: string;
messages: string[];
}) {
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
const html = this.mailsService.generateWarningEmailTemplate(
title,
messages,
);
this.mailsService.sendHtmlMailJob({
to: mails,
html: html,
subject:
'[WARNING] Auto Auctions System ' +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
@OnEvent(Event.SEND_SUCCESS)
async handleSendSuccess({
title,
messages,
}: {
title: string;
messages: string[];
}) {
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
const html = this.mailsService.generateSuccessEmailTemplate(
title,
messages,
);
this.mailsService.sendHtmlMailJob({
to: mails,
html: html,
subject:
'[SUCCESS] Auto Auctions System ' +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
@OnEvent(Event.SEND_ERROR)
async handleSendError({
title,
messages,
}: {
title: string;
messages: string[];
}) {
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
const html = this.mailsService.generateErrorEmailTemplate(title, messages);
this.mailsService.sendHtmlMailJob({
to: mails,
html: html,
subject:
'[ERROR] Auto Auctions System ' +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
@OnEvent(Event.SEND_INFO)
async handleSendInfo({
title,
messages,
}: {
title: string;
messages: string[];
}) {
const mails =
(await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
const html = this.mailsService.generateInfoEmailTemplate(title, messages);
this.mailsService.sendHtmlMailJob({
to: mails,
html: html,
subject:
'[INFO] Auto Auctions System ' +
moment(new Date()).format('YYYY-MM-DD HH:mm'),
});
}
}

View File

@ -8,11 +8,13 @@ import { AdminNotificationListener } from './listeners/admin-notification.listen
import { NotificationService } from './notification.service';
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
import { MailsModule } from '../mails/mails.module';
@Module({
imports: [
forwardRef(() => BidsModule),
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
MailsModule,
],
controllers: [NotificationController, ClientNotificationController],
providers: [NotificationService, AdminNotificationListener],

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Bid } from '../bids/entities/bid.entity';
import { NAME_EVENTS } from './constants';
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
import { InjectRepository } from '@nestjs/typeorm';
import { Notification } from './entities/notification.entity';
@ -17,6 +16,7 @@ import { Column } from 'nestjs-paginate/lib/helper';
import AppResponse from '@/response/app-response';
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
import { Event } from '../bids/utils/events';
@Injectable()
export class NotificationService {
@ -109,18 +109,40 @@ export class NotificationService {
send_to: JSON.stringify(sendToData),
});
try {
const prevAnyMessage = await this.sendMessageRepo.findOne({
where: {
bid: { id: bid.id },
message: notification.message,
type: bid.status,
max_price: bid.max_price,
reserve_price: bid.reserve_price,
},
});
if (prevAnyMessage) return;
await this.sendMessageRepo.save({
bid: { id: bid.id },
message: notification.message,
type: bid.status,
max_price: bid.max_price,
reserve_price: bid.reserve_price,
});
this.eventEmitter.emit(NAME_EVENTS.BID_STATUS, {
this.eventEmitter.emit(Event.BID_STATUS, {
bid: {
...bid,
status: 'out-bid',
// status: 'out-bid',
},
notification,
});
} catch (error) {
console.log(
'%csrc/modules/notification/notification.service.ts:131 Error',
'color: #007acc;',
Error,
);
}
}
}

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { ScrapConfigsService } from '../../services/scrap-config.service';
@Controller('scrap-configs')
export class ClientScrapConfigsController {
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
@Get()
async clientGetScrapeConfigs() {
return await this.scrapConfigsService.clientGetScrapeConfigs();
}
}

View File

@ -0,0 +1,23 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ScrapConfigsService } from '../../services/scrap-config.service';
import { ScrapItemsService } from '../../services/scrap-item-config.service';
import { UpsertScrapItemDto } from '../../dto/scrap-items/upsert-scrap-item.dto';
import { ScrapItem } from '../../entities/scrap-item.entity';
import { Paginate, PaginateQuery } from 'nestjs-paginate';
@Controller('scrap-items')
export class ClientScrapItemsController {
constructor(private readonly scrapItemsService: ScrapItemsService) {}
@Post('upsert')
async upsertScrapItems(@Body() data: UpsertScrapItemDto[]) {
return await this.scrapItemsService.upsertScrapItemsRes(
data as ScrapItem[],
);
}
@Get()
async index(@Paginate() query: PaginateQuery) {
return await this.scrapItemsService.index(query);
}
}

View File

@ -0,0 +1,23 @@
import { Body, Controller, Param, Post, Put } from '@nestjs/common';
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
import { ScrapConfig } from '../entities/scrap-config.entity';
import { ScrapConfigsService } from '../services/scrap-config.service';
@Controller('admin/scrap-configs')
export class ScrapConfigsController {
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
@Post()
async create(@Body() data: CreateScrapConfigDto) {
return await this.scrapConfigsService.create(data);
}
@Put(':id')
async update(
@Param('id') id: ScrapConfig['id'],
@Body() data: UpdateScrapConfigDto,
) {
return await this.scrapConfigsService.update(id, data);
}
}

View File

@ -0,0 +1,23 @@
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
IsUrl,
} from 'class-validator';
export class CreateScrapConfigDto {
@IsUrl()
search_url: string;
@IsBoolean()
@IsOptional()
enable: boolean;
@IsString()
@IsOptional()
keywords: string;
@IsNumber()
web_id: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateScrapConfigDto } from './create-scrap-config';
export class UpdateScrapConfigDto extends PartialType(CreateScrapConfigDto) {}

View File

@ -0,0 +1,18 @@
import { IsNumber, IsString, IsUrl } from 'class-validator';
export class UpsertScrapItemDto {
@IsUrl()
url: string;
@IsString()
image_url: string;
@IsString()
name: string;
@IsString()
keyword: string;
@IsNumber()
current_price: number;
@IsNumber()
scrap_config_id: number;
@IsString()
model: string;
}

View File

@ -0,0 +1,36 @@
import { WebBid } from '@/modules/bids/entities/wed-bid.entity';
import {
Column,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { ScrapItem } from './scrap-item.entity';
import { Timestamp } from './timestamp';
@Entity('scrap-configs')
export class ScrapConfig extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true })
search_url: string;
@Column({ default: 'cisco' })
keywords: string;
@Column({ default: true })
enable: boolean;
@OneToOne(() => WebBid, (web) => web.scrap_config, { onDelete: 'CASCADE' })
@JoinColumn()
web_bid: WebBid;
@OneToMany(() => ScrapItem, (web) => web.scrap_config, {
onDelete: 'CASCADE',
})
scrap_items: ScrapItem[];
}

View File

@ -0,0 +1,44 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { ScrapConfig } from './scrap-config.entity';
import { Timestamp } from './timestamp';
@Entity('scrap-items')
@Unique(['model', 'scrap_config_id'])
export class ScrapItem extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column()
name: string;
@Column({ type: 'text' })
url: string;
@Column()
model: string;
@Column({ nullable: true, default: null, type: 'text' })
image_url: string | null;
@Column({ nullable: true, default: null })
keyword: string;
@Column({ nullable: true, default: null, type: 'float' })
current_price: number;
@Column()
scrap_config_id: number;
@ManyToOne(() => ScrapConfig, (web) => web.scrap_items, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'scrap_config_id' })
scrap_config: ScrapConfig;
}

View File

@ -0,0 +1,8 @@
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class Timestamp {
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updated_at: Date;
}

View File

@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScrapConfig } from './entities/scrap-config.entity';
import { ScrapItem } from './entities/scrap-item.entity';
import { ScrapConfigsService } from './services/scrap-config.service';
import { ScrapConfigsController } from './controllers/scrap-config.controller';
import { TasksService } from './services/tasks.service';
import { ScrapItemsService } from './services/scrap-item-config.service';
import { MailsModule } from '../mails/mails.module';
import { BidsModule } from '../bids/bids.module';
import { ClientScrapConfigsController } from './controllers/client/scrap-configs.controller';
import { ClientScrapItemsController } from './controllers/client/scrap-items.controller';
@Module({
imports: [
TypeOrmModule.forFeature([ScrapConfig, ScrapItem]),
MailsModule,
BidsModule,
],
providers: [ScrapConfigsService, TasksService, ScrapItemsService],
exports: [ScrapConfigsService, TasksService, ScrapItemsService],
controllers: [
ScrapConfigsController,
ClientScrapConfigsController,
ClientScrapItemsController,
],
})
export class ScrapsModule {}

View File

@ -0,0 +1,54 @@
import AppResponse from '@/response/app-response';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { plainToClass } from 'class-transformer';
import { IsNull, Not, Repository } from 'typeorm';
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
import { ScrapConfig } from '../entities/scrap-config.entity';
@Injectable()
export class ScrapConfigsService {
constructor(
@InjectRepository(ScrapConfig)
readonly scrapConfigRepo: Repository<ScrapConfig>,
) {}
async clientGetScrapeConfigs() {
const data = await this.scrapConfigRepo.find({
where: {
search_url: Not(IsNull()),
keywords: Not(IsNull()),
enable: true,
},
relations: {
web_bid: true,
},
});
return AppResponse.toResponse(plainToClass(ScrapConfig, data));
}
async create(data: CreateScrapConfigDto) {
const result = await this.scrapConfigRepo.save({
search_url: data.search_url,
keywords: data.keywords,
web_bid: { id: data.web_id },
});
if (!result) return AppResponse.toResponse(false);
return AppResponse.toResponse(true);
}
async update(
id: ScrapConfig['id'],
{ web_id, ...data }: UpdateScrapConfigDto,
) {
const result = await this.scrapConfigRepo.update(id, { ...data });
if (!result.affected) return AppResponse.toResponse(false);
return AppResponse.toResponse(true);
}
}

View File

@ -0,0 +1,106 @@
import { WebBidsService } from '@/modules/bids/services/web-bids.service';
import AppResponse from '@/response/app-response';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { paginate, PaginateQuery } from 'nestjs-paginate';
import { Repository } from 'typeorm';
import { ScrapItem } from '../entities/scrap-item.entity';
@Injectable()
export class ScrapItemsService {
constructor(
@InjectRepository(ScrapItem)
readonly scrapItemRepo: Repository<ScrapItem>,
private readonly webService: WebBidsService,
) {}
async upsertScrapItems(items: ScrapItem[]) {
if (!items.length) return { inserted: 0, updated: 0 };
// Lấy keys để query bản ghi đã tồn tại
const keys = items.map((d) => ({
model: d.model,
scrap_config_id: d.scrap_config_id,
}));
// Tìm các bản ghi đã có trong DB
const existingItems = await this.scrapItemRepo.findBy(keys);
const toUpdate = [];
const toInsert = [];
items.forEach((itemData) => {
const exist = existingItems.find(
(e) =>
e.model === itemData.model &&
e.scrap_config_id === itemData.scrap_config_id,
);
if (exist) {
toUpdate.push({
...exist,
...itemData,
updated_at: new Date(),
});
} else {
toInsert.push(itemData);
}
});
// Thực hiện insert
if (toInsert.length) {
await this.scrapItemRepo.insert(toInsert);
}
// Thực hiện update
if (toUpdate.length) {
await this.scrapItemRepo.save(toUpdate);
}
return {
inserted: toInsert.length,
updated: toUpdate.length,
};
}
async upsertScrapItemsRes(items: ScrapItem[]) {
const rs = await this.upsertScrapItems(items);
if (!rs) throw new BadRequestException(AppResponse.toResponse(null));
return AppResponse.toResponse(rs);
}
async index(query: PaginateQuery) {
const { data, meta } = await paginate(query, this.scrapItemRepo, {
sortableColumns: ['id'],
searchableColumns: ['id'],
defaultLimit: 15,
filterableColumns: {
'scrap_config.web_bid.origin_url': true,
},
defaultSortBy: [['updated_at', 'DESC']],
maxLimit: 100,
relations: { scrap_config: { web_bid: true } },
});
const sources = await this.webService.webBidRepo.find({
select: { origin_url: true },
});
const newData = data.map(({ scrap_config, ...item }) => {
return {
...item,
source: scrap_config.web_bid.origin_url,
};
});
return AppResponse.toPaginationAny({
data: newData,
meta,
bonus: {
sources: sources.map((item) => item.origin_url),
},
});
}
}

View File

@ -0,0 +1,104 @@
import { ConfigsService } from '@/modules/bids/services/configs.service';
import { DashboardService } from '@/modules/bids/services/dashboard.service';
import { MailsService } from '@/modules/mails/services/mails.service';
import { delay } from '@/ultils';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import * as moment from 'moment';
import { Between } from 'typeorm';
import { ScrapItemsService } from './scrap-item-config.service';
import { HotItemApi } from '@/modules/bids/apis/hot-item.api';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
constructor(
private readonly scrapItemsService: ScrapItemsService,
private readonly mailsService: MailsService,
private readonly configsSerivce: ConfigsService,
private readonly dashboardService: DashboardService,
private readonly hotItemApi: HotItemApi,
) {}
async runProcessAndSendReport(processName: string) {
const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
?.value;
if (!mails) {
console.warn('No mails configured for report. Skipping.');
return;
}
// Nếu process đang chạy, không chạy lại
const initialStatus =
await this.dashboardService.getStatusProcessByName(processName);
if (initialStatus === 'online') {
console.log(
`Process ${processName} is already running. Skipping execution.`,
);
return;
}
// Reset và chạy process
await this.dashboardService.resetProcessByName(processName);
console.log(`Process ${processName} started.`);
// Đợi process kết thúc, có timeout
const maxAttempts = 60; // 10 phút
let attempts = 0;
let status = 'online';
while (status === 'online' && attempts < maxAttempts) {
await delay(10000); // 10 giây
status = await this.dashboardService.getStatusProcessByName(processName);
attempts++;
}
if (status === 'online') {
console.warn(
`Process ${processName} still running after timeout. Skipping report.`,
);
return;
}
// Khi process kết thúc => gửi mail
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
try {
const data = await this.scrapItemsService.scrapItemRepo.find({
where: {
updated_at: Between(startOfDay, endOfDay),
},
relations: { scrap_config: { web_bid: true } },
order: { updated_at: 'DESC' },
});
const hotItems = await this.hotItemApi.listHotItem();
const { hasHotItem, html } = this.mailsService.generateProductTableHTML(
data,
hotItems,
);
await this.mailsService.sendHtmlMailJob({
to: mails,
subject: `${hasHotItem ? '[HOT ITEMS] ' : ''}Auction Items Matching Your Keywords Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
html: html,
});
console.log('Report mail sent successfully.');
} catch (err) {
console.error('Failed to generate or send report:', err);
}
}
// Thực hiện gọi scrape vào lúc 8h59 (giờ ÚC) mỗi ngày
@Cron('59 5 * * *')
async handleScraps() {
const processName = 'scrape-data-keyword';
await this.runProcessAndSendReport(processName);
}
}

View File

@ -25,7 +25,7 @@ export default class AppResponse {
}
public static toPagination<M>(
{ data, meta }: Paginated<M>,
{ data, meta, bonus }: Paginated<M> & { bonus?: Record<string, any> },
transform = true,
dtoClass?: new () => M,
) {
@ -45,6 +45,33 @@ export default class AppResponse {
last_page: meta.totalPages,
per_page: meta.itemsPerPage,
total: meta.totalItems,
...bonus,
},
});
}
public static toPaginationAny(
{ data, meta, bonus }: any,
transform = true,
dtoClass?: new () => any,
) {
const transformedData =
transform && dtoClass
? data.map((item) => plainToClass(dtoClass, item))
: data;
return this.toResponse(transformedData, {
bonus: {
current_page: meta.currentPage,
from: meta.currentPage * meta.itemsPerPage - (meta.itemsPerPage - 1),
to:
meta.currentPage * meta.itemsPerPage > meta.totalItems
? meta.totalItems
: meta.currentPage * meta.itemsPerPage,
last_page: meta.totalPages,
per_page: meta.itemsPerPage,
total: meta.totalItems,
...bonus,
},
});
}

View File

@ -10,6 +10,10 @@ export const excludeAuth = [
path: 'admin/auth/forgot-password',
method: RequestMethod.POST,
},
{
path: '/admin/bids/record/(.*)',
method: RequestMethod.GET,
},
] as (string | RouteInfo)[];
export const clientExcludeAuth = [
@ -18,6 +22,18 @@ export const clientExcludeAuth = [
path: 'auth/generate-token',
method: RequestMethod.POST,
},
{
path: 'extensions/policy',
method: RequestMethod.GET,
},
{
path: 'bids/hook-action',
method: RequestMethod.POST,
},
{
path: 'scrap-items',
method: RequestMethod.GET,
},
] as (string | RouteInfo)[];
export const excludeAuthor = [
@ -34,4 +50,8 @@ export const excludeAuthor = [
path: 'admin/auth/me',
method: RequestMethod.GET,
},
{
path: '/admin/bids/record/(.*)',
method: RequestMethod.GET,
},
] as (string | RouteInfo)[];

View File

@ -1,3 +1,7 @@
import { Bid } from '@/modules/bids/entities/bid.entity';
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
import * as moment from 'moment';
export function extractModelId(url: string): string | null {
switch (extractDomain(url)) {
case 'https://www.grays.com': {
@ -16,6 +20,10 @@ export function extractModelId(url: string): string | null {
const model = url.split('/').pop();
return model ? model : null;
}
case 'https://www.allbids.com.au': {
const match = url.match(/-(\d+)(?:[\?#]|$)/);
return match ? match[1] : null;
}
}
}
@ -25,6 +33,31 @@ export function subtractMinutes(timeStr: string, minutes: number) {
return date.toISOString(); // Trả về dạng chuẩn ISO
}
export function extractDomainSmart(url: string) {
const PUBLIC_SUFFIXES = ['com.au', 'co.uk', 'com.vn', 'org.au', 'gov.uk'];
try {
const hostname = new URL(url).hostname.replace(/^www\./, ''); // remove "www."
const parts = hostname.split('.');
for (let i = 0; i < PUBLIC_SUFFIXES.length; i++) {
if (hostname.endsWith(PUBLIC_SUFFIXES[i])) {
return parts[parts.length - PUBLIC_SUFFIXES[i].split('.').length - 1];
}
}
return parts[parts.length - 2];
} catch (e) {
return url;
}
}
export function subtractSeconds(time: string, seconds: number) {
const date = new Date(time);
date.setSeconds(date.getSeconds() - seconds);
return date.toUTCString();
}
export function isTimeReached(targetTime: string) {
const targetDate = new Date(targetTime);
const now = new Date();
@ -32,6 +65,19 @@ export function isTimeReached(targetTime: string) {
return now >= targetDate;
}
export function isTimePassedByMinutes(
targetTime: string,
minutes: number,
): boolean {
const targetDate = new Date(targetTime);
const now = new Date();
const diffMs = now.getTime() - targetDate.getTime(); // hiệu số milliseconds
const diffMinutes = diffMs / (1000 * 60);
return diffMinutes >= minutes;
}
export function extractDomain(url: string): string | null {
try {
const parsedUrl = new URL(url);
@ -101,3 +147,117 @@ export function verifyCode(content: string) {
// Nếu không tìm thấy mã xác minh, trả về null
return null;
}
export function shouldResetTool(
bids: Bid[],
lastResetTime: Date | null,
now: Date = new Date(),
) {
const ONE_MINUTE = 60 * 1000;
const ONE_HOUR = 60 * ONE_MINUTE;
const TWO_HOURS = 2 * ONE_HOUR;
const TWENTY_MINUTES = 20 * ONE_MINUTE;
const FIVE_MINUTES = 5 * ONE_MINUTE;
// Nếu đã reset trong 1 giờ gần đây => không reset
if (lastResetTime && now.getTime() - lastResetTime.getTime() < ONE_HOUR) {
return {
shouldReset: false,
};
}
// 1. Kiểm tra bid gần nhất có close_time trong vòng 20 phút tới
const futureBids = bids
.filter((b) => b.close_time)
.map((b) => ({
...b,
closeTime: new Date(b.close_time!),
}))
.filter((b) => b.closeTime.getTime() > now.getTime())
.sort((a, b) => a.closeTime.getTime() - b.closeTime.getTime());
const closest = futureBids[0];
const hasBidCloseSoon =
closest && closest.closeTime.getTime() - now.getTime() <= TWENTY_MINUTES;
if (hasBidCloseSoon) {
return {
shouldReset: true,
reason: 'Bid close_time is within 20 minutes',
bidId: closest.id,
closeTime: closest.close_time,
};
}
// 2. Kiểm tra bid chưa có lot_id hoặc close_time, được tạo > 5 phút và cách reset trước > 2 tiếng
// Đồng thời không có bid nào sắp close trong 20 phút tới
if (!hasBidCloseSoon) {
for (const bid of bids) {
const createdAt = new Date(bid.created_at);
if (
(!bid.lot_id || !bid.close_time) &&
now.getTime() - createdAt.getTime() > FIVE_MINUTES &&
(!lastResetTime || now.getTime() - lastResetTime.getTime() > TWO_HOURS)
) {
return {
shouldReset: true,
reason:
'Bid is missing info and older than 5 mins, last reset > 2h, and no urgent bids',
bidId: bid.id,
closeTime: bid.close_time,
};
}
}
}
return {
shouldReset: false,
};
}
export function extractNumber(str: string) {
const match = str.match(/\d+(\.\d+)?/);
return match ? parseFloat(match[0]) : null;
}
export function formatEndTime(
closeTime: string | Date,
extended: boolean,
): string {
return `${moment(closeTime).format('YYYY-MM-DD HH:mm')} (${extended ? 'extended' : 'no extension'})`;
}
export const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
export const isHotItemFn = (item: ScrapItem, hotItems: any[]) => {
return hotItems.some((obj) =>
item.name.toLowerCase().includes(obj.name.toLowerCase()),
);
};
export function parseVideoFileName(fileName: string) {
// Loại bỏ phần mở rộng .mp4
const nameWithoutExt = fileName.replace(/\.mp4$/, '');
// Tách phần ID đầu tiên
const firstUnderscoreIndex = nameWithoutExt.indexOf('_');
const id = nameWithoutExt.slice(0, firstUnderscoreIndex);
// Tách phần timestamp cuối cùng
const lastUnderscoreIndex = nameWithoutExt.lastIndexOf('_');
const timestamp = nameWithoutExt.slice(lastUnderscoreIndex + 1);
// Tên sản phẩm nằm giữa 2 phần còn lại
const name = nameWithoutExt.slice(
firstUnderscoreIndex + 1,
lastUnderscoreIndex,
);
return {
bid_id: Number(id),
name,
original_name: fileName,
timestamp: Number(timestamp),
};
}

View File

@ -1,9 +1,18 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": ["ts", "js", "json"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"preset": "ts-jest",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testMatch": ["**/?(*.)+(spec|test).ts"],
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
},
"globals": {
"ts-jest": {
"tsconfig": "<rootDir>/tsconfig.json"
}
}
}

View File

@ -13,11 +13,13 @@ import browser from "./system/browser.js";
import configs from "./system/config.js";
import {
delay,
findNearestClosingChild,
extractModelId,
isTimeReached,
randomDelayWithMeta,
safeClosePage,
subtractSeconds,
} from "./system/utils.js";
import CONSTANTS from "./system/constants.js";
global.IS_CLEANING = true;
@ -146,6 +148,11 @@ const tracking = async () => {
Promise.allSettled(
productTabs.map(async (productTab) => {
console.log(`📌 Processing Product ID: ${productTab.id}`);
console.log(
`🔍 [${
productTab.id
}] Current URL: ${await productTab.page_context?.url?.()}`
);
// Xác định parent context
if (!productTab.parent_browser_context) {
@ -162,7 +169,7 @@ const tracking = async () => {
// Thời điểm tracking liên tục
const earlyTrackingTime = subtractSeconds(
productTab.close_time,
productTab?.web_bid?.early_tracking_seconds || 0
productTab.getEarlyTrackingSeconds() || 0
);
// Check không mở tab nếu chưa đến giờ
@ -184,6 +191,8 @@ const tracking = async () => {
// Kiểm tra URL và điều hướng nếu cần
if ((await productTab.page_context.url()) !== productTab.url) {
if (global[`IS_PLACE_BID-${productTab.id}`]) return;
console.log(
`🔄 Redirecting to new URL for Product ID: ${productTab.id}`
);
@ -219,8 +228,6 @@ const tracking = async () => {
return;
}
// Thực thi hành động
console.log(`🚀 Executing action for Product ID: ${productTab.id}`);
await productTab.action();
})
);
@ -272,21 +279,6 @@ const clearLazyTab = async () => {
// product tabs
const productTabs = _.flatMap(MANAGER_BIDS, "children");
// for (const item of [...productTabs, ...MANAGER_BIDS]) {
// if (!item.page_context) continue;
// try {
// const avalableResult = await isPageAvailable(item.page_context);
// if (!avalableResult) {
// await safeClosePage(item);
// }
// } catch (e) {
// console.warn("⚠️ Error checking page_context.title()", e.message);
// await safeClosePage(item);
// }
// }
for (const page of pages) {
try {
if (page.isClosed()) continue; // Trang đã đóng thì bỏ qua
@ -302,12 +294,12 @@ const clearLazyTab = async () => {
const earlyTrackingTime = subtractSeconds(
productTab.close_time,
productTab?.web_bid?.early_tracking_seconds || 0
productTab.getEarlyTrackingSeconds() || 0
);
if (!isTimeReached(earlyTrackingTime)) {
await safeClosePage(productTab);
console.log(`🛑 Unused page detected: ${pageUrl}`);
console.log(`🛑 Unused page detectedd: ${pageUrl}`);
continue;
}
@ -315,11 +307,32 @@ const clearLazyTab = async () => {
continue;
}
const modelProductTab = extractModelId(pageUrl);
if (modelProductTab) {
const productWatingUpdate = productTabs.find(
(item) =>
item.model === modelProductTab &&
isTimeReached(item.close_time) &&
item.status === "biding"
);
if (productWatingUpdate) {
console.log("Waiting product update to close");
continue;
}
}
// remove all listents
page.removeAllListeners();
console.log(`🛑 Unused page detected: ${pageUrl}`);
if (CONSTANTS.KEEP_LIVE_URLS.some((item) => pageUrl.includes(item))) {
console.log("Url include keep live urls: " + pageUrl);
continue;
}
const bidData = MANAGER_BIDS.filter((item) => item.page_context)
.map((i) => ({
current_url: i.page_context.url(),

Some files were not shown because too many files have changed in this diff Show More