Compare commits
9 commits
6bb9bd40dd
...
a06863562c
| Author | SHA1 | Date | |
|---|---|---|---|
| a06863562c | |||
| 3bab4e4c56 | |||
| 5d0ce42592 | |||
| 96334a595f | |||
| 2f32cb7deb | |||
| 7b45659a50 | |||
| c065835d7a | |||
| d2f0d9767b | |||
| 6a37e8d1f0 |
23 changed files with 5662 additions and 740 deletions
337
backend/package-lock.json
generated
337
backend/package-lock.json
generated
|
|
@ -1,337 +0,0 @@
|
||||||
{
|
|
||||||
"name": "rocks-2many-backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"requires": true,
|
|
||||||
"dependencies": {
|
|
||||||
"append-field": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
|
|
||||||
},
|
|
||||||
"asynckit": {
|
|
||||||
"version": "0.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
|
||||||
},
|
|
||||||
"axios": {
|
|
||||||
"version": "1.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
|
|
||||||
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
|
|
||||||
"requires": {
|
|
||||||
"follow-redirects": "^1.15.6",
|
|
||||||
"form-data": "^4.0.0",
|
|
||||||
"proxy-from-env": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buffer-from": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
|
||||||
},
|
|
||||||
"busboy": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
|
||||||
"requires": {
|
|
||||||
"streamsearch": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"requires": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"combined-stream": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
|
||||||
"requires": {
|
|
||||||
"delayed-stream": "~1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"concat-stream": {
|
|
||||||
"version": "1.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
|
||||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
|
||||||
"requires": {
|
|
||||||
"buffer-from": "^1.0.0",
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"readable-stream": "^2.2.2",
|
|
||||||
"typedarray": "^0.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"core-util-is": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
|
||||||
},
|
|
||||||
"csv-parser": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA=="
|
|
||||||
},
|
|
||||||
"csv-writer": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g=="
|
|
||||||
},
|
|
||||||
"delayed-stream": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
|
|
||||||
},
|
|
||||||
"dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"requires": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
|
|
||||||
},
|
|
||||||
"es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
|
|
||||||
},
|
|
||||||
"es-object-atoms": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
|
||||||
"requires": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es-set-tostringtag": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
|
||||||
"requires": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.6",
|
|
||||||
"has-tostringtag": "^1.0.2",
|
|
||||||
"hasown": "^2.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"follow-redirects": {
|
|
||||||
"version": "1.15.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
|
||||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
|
|
||||||
},
|
|
||||||
"form-data": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
|
||||||
"requires": {
|
|
||||||
"asynckit": "^0.4.0",
|
|
||||||
"combined-stream": "^1.0.8",
|
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"mime-types": "^2.1.12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"function-bind": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
|
|
||||||
},
|
|
||||||
"get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"requires": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"requires": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
|
|
||||||
},
|
|
||||||
"has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
|
|
||||||
},
|
|
||||||
"has-tostringtag": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
|
||||||
"requires": {
|
|
||||||
"has-symbols": "^1.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hasown": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
|
||||||
"requires": {
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"inherits": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
|
||||||
},
|
|
||||||
"isarray": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
|
||||||
},
|
|
||||||
"math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
|
|
||||||
},
|
|
||||||
"media-typer": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
|
|
||||||
},
|
|
||||||
"mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
|
||||||
},
|
|
||||||
"mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"requires": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minimist": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
|
|
||||||
},
|
|
||||||
"mkdirp": {
|
|
||||||
"version": "0.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
|
||||||
"requires": {
|
|
||||||
"minimist": "^1.2.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"multer": {
|
|
||||||
"version": "1.4.5-lts.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
|
||||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
|
||||||
"requires": {
|
|
||||||
"append-field": "^1.0.0",
|
|
||||||
"busboy": "^1.0.0",
|
|
||||||
"concat-stream": "^1.5.2",
|
|
||||||
"mkdirp": "^0.5.4",
|
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.4",
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"object-assign": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
|
||||||
},
|
|
||||||
"process-nextick-args": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
|
||||||
},
|
|
||||||
"proxy-from-env": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
|
||||||
},
|
|
||||||
"readable-stream": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
|
||||||
"requires": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.3",
|
|
||||||
"isarray": "~1.0.0",
|
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
|
||||||
},
|
|
||||||
"slugify": {
|
|
||||||
"version": "1.6.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz",
|
|
||||||
"integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="
|
|
||||||
},
|
|
||||||
"streamsearch": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
|
|
||||||
},
|
|
||||||
"string_decoder": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
|
||||||
"requires": {
|
|
||||||
"safe-buffer": "~5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type-is": {
|
|
||||||
"version": "1.6.18",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
|
||||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
|
||||||
"requires": {
|
|
||||||
"media-typer": "0.3.0",
|
|
||||||
"mime-types": "~2.1.24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"typedarray": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
|
||||||
},
|
|
||||||
"util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
|
||||||
},
|
|
||||||
"xtend": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,14 +9,18 @@
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.802.0",
|
||||||
|
"@aws-sdk/client-sqs": "^3.799.0",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"csv-writer": "^1.6.0",
|
"csv-writer": "^1.6.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"multer-s3": "^3.0.1",
|
||||||
"nodemailer": "^6.9.1",
|
"nodemailer": "^6.9.1",
|
||||||
"pg": "^8.10.0",
|
"pg": "^8.10.0",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 684 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 684 KiB |
|
|
@ -3,6 +3,7 @@ const dotenv = require('dotenv');
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
// Server configuration
|
// Server configuration
|
||||||
port: process.env.PORT || 4000,
|
port: process.env.PORT || 4000,
|
||||||
|
|
@ -15,7 +16,9 @@ const config = {
|
||||||
user: process.env.DB_USER || 'postgres',
|
user: process.env.DB_USER || 'postgres',
|
||||||
password: process.env.DB_PASSWORD || 'postgres',
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
database: process.env.DB_NAME || 'ecommerce',
|
database: process.env.DB_NAME || 'ecommerce',
|
||||||
port: process.env.DB_PORT || 5432
|
port: process.env.DB_PORT || 5432,
|
||||||
|
readHost: process.env.DB_READ_HOST || process.env.DB_HOST || 'db',
|
||||||
|
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email configuration
|
// Email configuration
|
||||||
|
|
@ -65,6 +68,16 @@ const config = {
|
||||||
protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http',
|
protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http',
|
||||||
apiDomain: process.env.ENVIRONMENT === 'prod' ? (process.env.API_PROD_URL || 'api.rocks.2many.ca') : 'localhost:4000',
|
apiDomain: process.env.ENVIRONMENT === 'prod' ? (process.env.API_PROD_URL || 'api.rocks.2many.ca') : 'localhost:4000',
|
||||||
analyticApiKey: process.env.SITE_ANALYTIC_API || '',
|
analyticApiKey: process.env.SITE_ANALYTIC_API || '',
|
||||||
|
deployment: process.env.DEPLOYMENT_MODE || 'self-hosted',
|
||||||
|
redisHost: process.env.REDIS_HOST || '',
|
||||||
|
redisTLS: process.env.REDIS_TLS || '',
|
||||||
|
awsRegion: process.env.AWS_REGION || '',
|
||||||
|
awsS3Bucket: process.env.S3_BUCKET || '',
|
||||||
|
cdnDomain: process.env.CDN_DOMAIN || '',
|
||||||
|
awsQueueUrl: process.env.SQS_QUEUE_URL || '',
|
||||||
|
sessionSecret: process.env.SESSION_SECRET || '',
|
||||||
|
redisPort: process.env.REDIS_PORT || '',
|
||||||
|
redisPassword: process.env.REDIS_PASSWORD || ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -146,7 +159,6 @@ config.updateFromDatabase = (settings) => {
|
||||||
// Update carriers allowed
|
// Update carriers allowed
|
||||||
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
|
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update site settings if they exist in DB
|
// Update site settings if they exist in DB
|
||||||
const siteSettings = settings.filter(s => s.category === 'site');
|
const siteSettings = settings.filter(s => s.category === 'site');
|
||||||
if (siteSettings.length > 0) {
|
if (siteSettings.length > 0) {
|
||||||
|
|
@ -154,6 +166,33 @@ config.updateFromDatabase = (settings) => {
|
||||||
const siteProtocol = siteSettings.find(s => s.key === 'site_protocol');
|
const siteProtocol = siteSettings.find(s => s.key === 'site_protocol');
|
||||||
const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain');
|
const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain');
|
||||||
const analyticApiKey = siteSettings.find(s => s.key === 'site_analytics_api_key');
|
const analyticApiKey = siteSettings.find(s => s.key === 'site_analytics_api_key');
|
||||||
|
const redisHost = siteSettings.find(s => s.key === 'site_redis_host');
|
||||||
|
const redisTLS = siteSettings.find(s => s.key === 'site_redis_tls');
|
||||||
|
const awsRegion = siteSettings.find(s => s.key === 'site_aws_region');
|
||||||
|
const awsS3Bucket = siteSettings.find(s => s.key === 'site_aws_s3_bucket');
|
||||||
|
const cdnDomain = siteSettings.find(s => s.key === 'site_cdn_domain');
|
||||||
|
const deployment = siteSettings.find(s => s.key === 'site_deployment');
|
||||||
|
const awsQueueUrl = siteSettings.find(s => s.key === 'site_aws_queue_url');
|
||||||
|
const readHost = siteSettings.find(s => s.key === 'site_read_host');
|
||||||
|
const maxConnections = siteSettings.find(s => s.key === 'site_db_max_connections');
|
||||||
|
const sessionSecret = siteSettings.find(s => s.key === 'site_session_secret');
|
||||||
|
const redisPort = siteSettings.find(s => s.key === 'site_redis_port');
|
||||||
|
const redisPassword = siteSettings.find(s => s.key === 'site_redis_password');
|
||||||
|
|
||||||
|
if (redisHost && redisHost.value) config.site.redisHost = redisHost.value;
|
||||||
|
if (redisTLS && redisTLS.value) config.site.redisTLS = redisHost.value;
|
||||||
|
if (awsRegion && awsRegion.value) config.site.awsRegion = awsRegion.value;
|
||||||
|
if (awsS3Bucket && awsS3Bucket.value) config.site.awsS3Bucket = awsS3Bucket.value;
|
||||||
|
if (cdnDomain && cdnDomain.value) config.site.cdnDomain = cdnDomain.value;
|
||||||
|
if (deployment && deployment.value) config.site.deployment = deployment.value;
|
||||||
|
if (awsQueueUrl && awsQueueUrl.value) config.site.awsQueueUrl = awsQueueUrl.value;
|
||||||
|
if (readHost && readHost.value) config.db.readHost = readHost.value;
|
||||||
|
if (maxConnections && maxConnections.value) config.db.maxConnections = maxConnections.value;
|
||||||
|
if (sessionSecret && sessionSecret.value) config.site.sessionSecret = sessionSecret.value;
|
||||||
|
if (redisPort && redisPort.value) config.site.redisPort = redisPort.value;
|
||||||
|
if (redisPassword && redisPassword.value) config.site.redisPassword = redisPassword.value;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (siteDomain && siteDomain.value) config.site.domain = siteDomain.value;
|
if (siteDomain && siteDomain.value) config.site.domain = siteDomain.value;
|
||||||
if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value;
|
if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,73 @@
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const config = require('../config')
|
const config = require('../config')
|
||||||
|
|
||||||
// Create a pool instance
|
let writePool, readPool;
|
||||||
const pool = new Pool({
|
|
||||||
|
// Always create write pool
|
||||||
|
writePool = new Pool({
|
||||||
user: config.db.user,
|
user: config.db.user,
|
||||||
password: config.db.password,
|
password: config.db.password,
|
||||||
host: config.db.host,
|
host: config.db.host,
|
||||||
port: config.db.port,
|
port: config.db.port,
|
||||||
database: config.db.database
|
database: config.db.database,
|
||||||
|
max: config.db.maxConnections,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function for running queries
|
// Create read pool only in cloud mode
|
||||||
const query = async (text, params) => {
|
if (config.site.deployment === 'cloud') {
|
||||||
|
readPool = new Pool({
|
||||||
|
user: config.db.user,
|
||||||
|
password: config.db.password,
|
||||||
|
host: config.db.readHost,
|
||||||
|
port: config.db.port,
|
||||||
|
database: config.db.database,
|
||||||
|
max: config.db.maxConnections,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// In self-hosted mode, use the same pool for reads and writes
|
||||||
|
readPool = writePool;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function that automatically routes queries
|
||||||
|
const query = async (text, params, options = {}) => {
|
||||||
|
const { forceWrite = false } = options;
|
||||||
|
|
||||||
|
const isWrite = forceWrite ||
|
||||||
|
text.trim().toUpperCase().startsWith('INSERT') ||
|
||||||
|
text.trim().toUpperCase().startsWith('UPDATE') ||
|
||||||
|
text.trim().toUpperCase().startsWith('DELETE') ||
|
||||||
|
text.trim().toUpperCase().includes('FOR UPDATE');
|
||||||
|
|
||||||
|
const pool = isWrite ? writePool : readPool;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const res = await pool.query(text, params);
|
const res = await pool.query(text, params);
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
if (config.site.deployment === 'cloud') {
|
||||||
|
console.log('Executed query', {
|
||||||
|
text,
|
||||||
|
duration,
|
||||||
|
rows: res.rowCount,
|
||||||
|
pool: isWrite ? 'write' : 'read'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
console.log('Executed query', { text, duration, rows: res.rowCount });
|
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||||
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
query,
|
// Old Query function
|
||||||
pool
|
// const query = async (text, params) => {
|
||||||
};
|
// const start = Date.now();
|
||||||
|
// const res = await pool.query(text, params);
|
||||||
|
// const duration = Date.now() - start;
|
||||||
|
// console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||||
|
// return res;
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = { query, pool: writePool };
|
||||||
|
|
@ -13,6 +13,7 @@ const fs = require('fs');
|
||||||
// services
|
// services
|
||||||
const notificationService = require('./services/notificationService');
|
const notificationService = require('./services/notificationService');
|
||||||
const emailService = require('./services/emailService');
|
const emailService = require('./services/emailService');
|
||||||
|
const storageService = require('./services/storageService');
|
||||||
|
|
||||||
// routes
|
// routes
|
||||||
const stripePaymentRoutes = require('./routes/stripePayment');
|
const stripePaymentRoutes = require('./routes/stripePayment');
|
||||||
|
|
@ -59,45 +60,46 @@ if (!fs.existsSync(blogImagesDir)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure storage
|
// Configure storage
|
||||||
const storage = multer.diskStorage({
|
// const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
// destination: (req, file, cb) => {
|
||||||
// Determine destination based on upload type
|
// // Determine destination based on upload type
|
||||||
if (req.originalUrl.includes('/product')) {
|
// if (req.originalUrl.includes('/product')) {
|
||||||
cb(null, productImagesDir);
|
// cb(null, productImagesDir);
|
||||||
} else {
|
// } else {
|
||||||
cb(null, uploadsDir);
|
// cb(null, uploadsDir);
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
filename: (req, file, cb) => {
|
// filename: (req, file, cb) => {
|
||||||
// Create unique filename with original extension
|
// // Create unique filename with original extension
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
// const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
const fileExt = path.extname(file.originalname);
|
// const fileExt = path.extname(file.originalname);
|
||||||
const safeName = path.basename(file.originalname, fileExt)
|
// const safeName = path.basename(file.originalname, fileExt)
|
||||||
.toLowerCase()
|
// .toLowerCase()
|
||||||
.replace(/[^a-z0-9]/g, '-');
|
// .replace(/[^a-z0-9]/g, '-');
|
||||||
|
|
||||||
cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
|
// cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
|
||||||
// File filter to only allow images
|
// // File filter to only allow images
|
||||||
const fileFilter = (req, file, cb) => {
|
// const fileFilter = (req, file, cb) => {
|
||||||
// Accept only image files
|
// // Accept only image files
|
||||||
if (file.mimetype.startsWith('image/')) {
|
// if (file.mimetype.startsWith('image/')) {
|
||||||
cb(null, true);
|
// cb(null, true);
|
||||||
} else {
|
// } else {
|
||||||
cb(new Error('Only image files are allowed!'), false);
|
// cb(new Error('Only image files are allowed!'), false);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Create the multer instance
|
// Create the multer instance
|
||||||
const upload = multer({
|
// const upload = multer({
|
||||||
storage,
|
// storage,
|
||||||
fileFilter,
|
// fileFilter,
|
||||||
limits: {
|
// limits: {
|
||||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
// fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
const upload = storageService.getUploadMiddleware();
|
||||||
|
|
||||||
pool.connect()
|
pool.connect()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
|
@ -197,11 +199,16 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
|
||||||
message: 'No image file provided'
|
message: 'No image file provided'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const imagePath = req.file.path ? `/uploads/${req.file.filename}` : req.file.location;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
imagePath: `/uploads/${req.file.filename}`
|
imagePath: storageService.getImageUrl(imagePath)
|
||||||
});
|
});
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// imagePath: `/uploads/${req.file.filename}`
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/public-file/:filename', (req, res) => {
|
app.get('/api/public-file/:filename', (req, res) => {
|
||||||
|
|
@ -249,13 +256,22 @@ app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the relative path to the image
|
// Get the relative path to the image
|
||||||
const imagePath = `/uploads/products/${req.file.filename}`;
|
const imagePath = req.file.path ?
|
||||||
|
`/uploads/products/${req.file.filename}` :
|
||||||
|
req.file.location;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
imagePath,
|
imagePath: storageService.getImageUrl(imagePath),
|
||||||
filename: req.file.filename
|
filename: req.file.filename || path.basename(req.file.location)
|
||||||
});
|
});
|
||||||
|
// const imagePath = `/uploads/products/${req.file.filename}`;
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// imagePath,
|
||||||
|
// filename: req.file.filename
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload multiple product images (admin only)
|
// Upload multiple product images (admin only)
|
||||||
|
|
@ -269,15 +285,30 @@ app.post('/api/image/products', adminAuthMiddleware(pool, query), upload.array('
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the relative paths to the images
|
// Get the relative paths to the images
|
||||||
const imagePaths = req.files.map(file => ({
|
const imagePaths = req.files.map(file => {
|
||||||
imagePath: `/uploads/products/${file.filename}`,
|
const imagePath = file.path ?
|
||||||
filename: file.filename
|
`/uploads/products/${file.filename}` :
|
||||||
}));
|
file.location;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imagePath: storageService.getImageUrl(imagePath),
|
||||||
|
filename: file.filename || path.basename(file.location)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
images: imagePaths
|
images: imagePaths
|
||||||
});
|
});
|
||||||
|
// const imagePaths = req.files.map(file => ({
|
||||||
|
// imagePath: `/uploads/products/${file.filename}`,
|
||||||
|
// filename: file.filename
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// res.json({
|
||||||
|
// success: true,
|
||||||
|
// images: imagePaths
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete product image (admin only)
|
// Delete product image (admin only)
|
||||||
|
|
@ -293,6 +324,12 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.site.deployment === 'cloud' && config.site.awsS3Bucket) {
|
||||||
|
// Implementation for S3 deletion would go here
|
||||||
|
// For now, we'll just log and continue
|
||||||
|
console.log('S3 file deletion not implemented yet');
|
||||||
|
} else {
|
||||||
|
// Delete from local filesystem
|
||||||
const filePath = path.join(__dirname, '../public/uploads/products', filename);
|
const filePath = path.join(__dirname, '../public/uploads/products', filename);
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
|
|
@ -305,6 +342,8 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
|
||||||
|
|
||||||
// Delete the file
|
// Delete the file
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -346,6 +385,7 @@ app.use((err, req, res, next) => {
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server running on port ${port} in ${config.environment} environment`);
|
console.log(`Server running on port ${port} in ${config.environment} environment`);
|
||||||
|
console.log(`Deployment mode: ${config.site.deployment || 'self-hosted'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
@ -68,7 +68,6 @@ module.exports = (pool, query) => {
|
||||||
router.post('/login-request', async (req, res, next) => {
|
router.post('/login-request', async (req, res, next) => {
|
||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
console.log('/login-request')
|
console.log('/login-request')
|
||||||
console.log(JSON.stringify(config, null, 4))
|
|
||||||
try {
|
try {
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
const userResult = await query(
|
const userResult = await query(
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,19 @@ const csv = require('csv-parser');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { createObjectCsvWriter } = require('csv-writer');
|
const { createObjectCsvWriter } = require('csv-writer');
|
||||||
|
const storageService = require('../services/storageService');
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
const upload = multer({
|
// const upload = multer({
|
||||||
dest: path.join(__dirname, '../uploads/temp'),
|
// dest: path.join(__dirname, '../uploads/temp'),
|
||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
// limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
// });
|
||||||
|
|
||||||
module.exports = (pool, query, authMiddleware) => {
|
module.exports = (pool, query, authMiddleware) => {
|
||||||
// Apply authentication middleware to all routes
|
// Apply authentication middleware to all routes
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
const upload = storageService.getUploadMiddleware();
|
||||||
/**
|
/**
|
||||||
* Get all mailing lists
|
* Get all mailing lists
|
||||||
* GET /api/admin/mailing-lists
|
* GET /api/admin/mailing-lists
|
||||||
|
|
@ -512,16 +514,45 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
// Process the CSV file
|
// Process the CSV file
|
||||||
const results = [];
|
const results = [];
|
||||||
|
// const processFile = () => {
|
||||||
|
// return new Promise((resolve, reject) => {
|
||||||
|
// fs.createReadStream(file.path)
|
||||||
|
// .pipe(csv())
|
||||||
|
// .on('data', (data) => results.push(data))
|
||||||
|
// .on('end', () => {
|
||||||
|
// // Clean up temp file
|
||||||
|
// fs.unlink(file.path, (err) => {
|
||||||
|
// if (err) console.error('Error deleting temp file:', err);
|
||||||
|
// });
|
||||||
|
// resolve(results);
|
||||||
|
// })
|
||||||
|
// .on('error', reject);
|
||||||
|
// });
|
||||||
|
// };
|
||||||
const processFile = () => {
|
const processFile = () => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
// For S3 uploads, file.path won't exist, but file.location will
|
||||||
|
const filePath = file.path || file.location;
|
||||||
|
|
||||||
|
// If S3 storage, we need to download the file first
|
||||||
|
if (!file.path && file.location) {
|
||||||
|
// Implementation for S3 file would go here
|
||||||
|
// For now, we'll reject as this would need a different approach
|
||||||
|
reject(new Error('S3 file processing not implemented'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local file processing
|
||||||
fs.createReadStream(file.path)
|
fs.createReadStream(file.path)
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', () => {
|
.on('end', () => {
|
||||||
// Clean up temp file
|
// Clean up temp file - only for local storage
|
||||||
|
if (file.path) {
|
||||||
fs.unlink(file.path, (err) => {
|
fs.unlink(file.path, (err) => {
|
||||||
if (err) console.error('Error deleting temp file:', err);
|
if (err) console.error('Error deleting temp file:', err);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
resolve(results);
|
resolve(results);
|
||||||
})
|
})
|
||||||
.on('error', reject);
|
.on('error', reject);
|
||||||
|
|
@ -683,6 +714,10 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
// Create a temp file for CSV
|
// Create a temp file for CSV
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`;
|
const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`;
|
||||||
|
const tempDir = path.join(__dirname, '../uploads/temp');
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
const filePath = path.join(__dirname, '../uploads/temp', fileName);
|
const filePath = path.join(__dirname, '../uploads/temp', fileName);
|
||||||
|
|
||||||
// Create CSV writer
|
// Create CSV writer
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
// Create a new product with multiple images
|
// Create a new product with multiple images
|
||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Check for array
|
||||||
|
let products = [];
|
||||||
|
if (req.body.products) {
|
||||||
|
products = req.body.products;
|
||||||
|
} else {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
|
@ -19,6 +24,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
lengthCm,
|
lengthCm,
|
||||||
widthCm,
|
widthCm,
|
||||||
heightCm,
|
heightCm,
|
||||||
|
stockNotification,
|
||||||
origin,
|
origin,
|
||||||
age,
|
age,
|
||||||
materialType,
|
materialType,
|
||||||
|
|
@ -27,132 +33,55 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
tags
|
tags
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate required fields
|
products.push({
|
||||||
if (!name || !description || !categoryName || !price || !stockQuantity) {
|
name,
|
||||||
return res.status(400).json({
|
description,
|
||||||
error: true,
|
categoryName,
|
||||||
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory'
|
price,
|
||||||
|
stockQuantity,
|
||||||
|
weightGrams,
|
||||||
|
lengthCm,
|
||||||
|
widthCm,
|
||||||
|
heightCm,
|
||||||
|
stockNotification,
|
||||||
|
origin,
|
||||||
|
age,
|
||||||
|
materialType,
|
||||||
|
color,
|
||||||
|
images: images || [],
|
||||||
|
tags
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin transaction
|
// Prepare promises array for concurrent execution
|
||||||
const client = await pool.connect();
|
const productPromises = products.map(product => createProduct(product, pool));
|
||||||
|
|
||||||
try {
|
// Execute all product creation promises concurrently
|
||||||
await client.query('BEGIN');
|
const results = await Promise.all(productPromises);
|
||||||
|
|
||||||
// Get category ID by name
|
// Separate successes and errors
|
||||||
const categoryResult = await client.query(
|
const success = results.filter(result => !result.error).map(result => ({
|
||||||
'SELECT id FROM product_categories WHERE name = $1',
|
message: 'Product created successfully',
|
||||||
[categoryName]
|
product: result.product,
|
||||||
);
|
code: 201
|
||||||
|
}));
|
||||||
|
|
||||||
if (categoryResult.rows.length === 0) {
|
const errs = results.filter(result => result.error);
|
||||||
return res.status(404).json({
|
|
||||||
error: true,
|
|
||||||
message: `Category "${categoryName}" not found`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryId = categoryResult.rows[0].id;
|
|
||||||
|
|
||||||
// Create product
|
|
||||||
const productId = uuidv4();
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO products (
|
|
||||||
id, name, description, category_id, price, stock_quantity,
|
|
||||||
weight_grams, length_cm, width_cm, height_cm,
|
|
||||||
origin, age, material_type, color
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
|
||||||
[
|
|
||||||
productId, name, description, categoryId, price, stockQuantity,
|
|
||||||
weightGrams || null, lengthCm || null, widthCm || null, heightCm || null,
|
|
||||||
origin || null, age || null, materialType || null, color || null
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add images if provided
|
|
||||||
if (images && images.length > 0) {
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
|
||||||
const { path, isPrimary = (i === 0) } = images[i];
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
|
|
||||||
[productId, path, i, isPrimary]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tags if provided
|
|
||||||
if (tags && tags.length > 0) {
|
|
||||||
for (const tagName of tags) {
|
|
||||||
// Get tag ID
|
|
||||||
let tagResult = await client.query(
|
|
||||||
'SELECT id FROM tags WHERE name = $1',
|
|
||||||
[tagName]
|
|
||||||
);
|
|
||||||
|
|
||||||
let tagId;
|
|
||||||
|
|
||||||
// If tag doesn't exist, create it
|
|
||||||
if (tagResult.rows.length === 0) {
|
|
||||||
const newTagResult = await client.query(
|
|
||||||
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
|
|
||||||
[tagName]
|
|
||||||
);
|
|
||||||
tagId = newTagResult.rows[0].id;
|
|
||||||
} else {
|
|
||||||
tagId = tagResult.rows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tag to product
|
|
||||||
await client.query(
|
|
||||||
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
|
|
||||||
[productId, tagId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
// Get complete product with images and tags
|
|
||||||
const productQuery = `
|
|
||||||
SELECT p.*,
|
|
||||||
pc.name as category_name,
|
|
||||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
|
||||||
json_agg(json_build_object(
|
|
||||||
'id', pi.id,
|
|
||||||
'path', pi.image_path,
|
|
||||||
'isPrimary', pi.is_primary,
|
|
||||||
'displayOrder', pi.display_order
|
|
||||||
)) FILTER (WHERE pi.id IS NOT NULL) AS images
|
|
||||||
FROM products p
|
|
||||||
JOIN product_categories pc ON p.category_id = pc.id
|
|
||||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
|
||||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
|
||||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
|
||||||
WHERE p.id = $1
|
|
||||||
GROUP BY p.id, pc.name
|
|
||||||
`;
|
|
||||||
|
|
||||||
const product = await query(productQuery, [productId]);
|
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
message: 'Product created successfully',
|
message: errs.length > 0 ?
|
||||||
product: product.rows[0]
|
(success.length > 0 ? 'Some Products failed to create, check errors array' : "Failed to create Products") :
|
||||||
|
'Products created successfully',
|
||||||
|
success,
|
||||||
|
errs
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/stock-notification', async (req, res, next) => {
|
router.post('/:id/stock-notification', async (req, res, next) => {
|
||||||
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { enabled, email, threshold } = req.body;
|
const { enabled, email, threshold } = req.body;
|
||||||
|
|
@ -164,6 +93,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
message: 'Admin access required'
|
message: 'Admin access required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Check if product exists
|
// Check if product exists
|
||||||
const productCheck = await query(
|
const productCheck = await query(
|
||||||
|
|
@ -186,23 +116,22 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update product with notification settings
|
// Update product with notification settings
|
||||||
const result = await query(
|
const result = await addThresholdNotification(id, enabled, email, threshold, client);
|
||||||
`UPDATE products
|
await client.query('COMMIT');
|
||||||
SET stock_notification = $1
|
|
||||||
WHERE id = $2
|
|
||||||
RETURNING *`,
|
|
||||||
[JSON.stringify(notificationSettings), id]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Stock notification settings updated successfully',
|
message: 'Stock notification settings updated successfully',
|
||||||
product: result.rows[0]
|
product: result.rows[0]
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
next(error);
|
next(error);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Update an existing product
|
// Update an existing product
|
||||||
router.put('/:id', async (req, res, next) => {
|
router.put('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -215,6 +144,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
stockQuantity,
|
stockQuantity,
|
||||||
weightGrams,
|
weightGrams,
|
||||||
lengthCm,
|
lengthCm,
|
||||||
|
stockNotification,
|
||||||
widthCm,
|
widthCm,
|
||||||
heightCm,
|
heightCm,
|
||||||
origin,
|
origin,
|
||||||
|
|
@ -345,6 +275,10 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
valueIndex++;
|
valueIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stockNotification) {
|
||||||
|
await addThresholdNotification(id, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
|
||||||
|
}
|
||||||
|
|
||||||
if (updateFields.length > 0) {
|
if (updateFields.length > 0) {
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE products
|
UPDATE products
|
||||||
|
|
@ -486,3 +420,178 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
async function addThresholdNotification(id, enabled, email, threshold ,client) {
|
||||||
|
// Store notification settings as JSONB
|
||||||
|
const notificationSettings = {
|
||||||
|
enabled,
|
||||||
|
email: email || null,
|
||||||
|
threshold: threshold || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update product with notification settings
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE products
|
||||||
|
SET stock_notification = $1
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[JSON.stringify(notificationSettings), id]
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProduct(product, pool) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
categoryName,
|
||||||
|
price,
|
||||||
|
stockQuantity,
|
||||||
|
weightGrams,
|
||||||
|
lengthCm,
|
||||||
|
widthCm,
|
||||||
|
heightCm,
|
||||||
|
stockNotification,
|
||||||
|
origin,
|
||||||
|
age,
|
||||||
|
materialType,
|
||||||
|
color,
|
||||||
|
images,
|
||||||
|
tags
|
||||||
|
} = product;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !description || !categoryName || !price || !stockQuantity) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory',
|
||||||
|
code: 400
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a client from the pool for this specific product
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Get category ID by name
|
||||||
|
const categoryResult = await client.query(
|
||||||
|
'SELECT id FROM product_categories WHERE name = $1',
|
||||||
|
[categoryName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryResult.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: `Category "${categoryName}" not found`,
|
||||||
|
code: 404
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryId = categoryResult.rows[0].id;
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
const productId = uuidv4();
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO products (
|
||||||
|
id, name, description, category_id, price, stock_quantity,
|
||||||
|
weight_grams, length_cm, width_cm, height_cm,
|
||||||
|
origin, age, material_type, color
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||||
|
[
|
||||||
|
productId, name, description, categoryId, price, stockQuantity,
|
||||||
|
weightGrams || null, lengthCm || null, widthCm || null, heightCm || null,
|
||||||
|
origin || null, age || null, materialType || null, color || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add images if provided
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
const imagePromises = images.map((image, i) => {
|
||||||
|
const { path, isPrimary = (i === 0) } = image;
|
||||||
|
return client.query(
|
||||||
|
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
|
||||||
|
[productId, path, i, isPrimary]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(imagePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tags if provided
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
for (const tagName of tags) {
|
||||||
|
// Get tag ID
|
||||||
|
let tagResult = await client.query(
|
||||||
|
'SELECT id FROM tags WHERE name = $1',
|
||||||
|
[tagName]
|
||||||
|
);
|
||||||
|
|
||||||
|
let tagId;
|
||||||
|
|
||||||
|
// If tag doesn't exist, create it
|
||||||
|
if (tagResult.rows.length === 0) {
|
||||||
|
const newTagResult = await client.query(
|
||||||
|
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
|
||||||
|
[tagName]
|
||||||
|
);
|
||||||
|
tagId = newTagResult.rows[0].id;
|
||||||
|
} else {
|
||||||
|
tagId = tagResult.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tag to product
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
|
||||||
|
[productId, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get complete product with images and tags
|
||||||
|
const productQuery = `
|
||||||
|
SELECT p.*,
|
||||||
|
pc.name as category_name,
|
||||||
|
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||||
|
json_agg(json_build_object(
|
||||||
|
'id', pi.id,
|
||||||
|
'path', pi.image_path,
|
||||||
|
'isPrimary', pi.is_primary,
|
||||||
|
'displayOrder', pi.display_order
|
||||||
|
)) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||||
|
FROM products p
|
||||||
|
JOIN product_categories pc ON p.category_id = pc.id
|
||||||
|
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||||
|
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||||
|
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
GROUP BY p.id, pc.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
const productResult = await client.query(productQuery, [productId]);
|
||||||
|
|
||||||
|
// Add threshold notification if provided
|
||||||
|
if (stockNotification) {
|
||||||
|
await addThresholdNotification(productId, stockNotification.enabled, stockNotification.email, stockNotification.threshold, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
product: productResult.rows[0]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: `Error creating product: ${error.message}`,
|
||||||
|
code: 500
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -224,9 +224,11 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
} else if (category === 'database') {
|
} else if (category === 'database') {
|
||||||
if (key === 'db_host') config.db.host = value;
|
if (key === 'db_host') config.db.host = value;
|
||||||
if (key === 'db_user') config.db.user = value;
|
if (key === 'db_user') config.db.user = value;
|
||||||
|
if (key === 'site_read_host') config.db.readHost = value;
|
||||||
if (key === 'db_password') config.db.password = value;
|
if (key === 'db_password') config.db.password = value;
|
||||||
if (key === 'db_name') config.db.database = value;
|
if (key === 'db_name') config.db.database = value;
|
||||||
if (key === 'db_port') config.db.port = parseInt(value, 10);
|
if (key === 'db_port') config.db.port = parseInt(value, 10);
|
||||||
|
if (key === 'site_db_max_connections') config.db.maxConnections = parseInt(value, 10);
|
||||||
} else if (category === 'email') {
|
} else if (category === 'email') {
|
||||||
if (key === 'smtp_host') config.email.host = value;
|
if (key === 'smtp_host') config.email.host = value;
|
||||||
if (key === 'smtp_port') config.email.port = parseInt(value, 10);
|
if (key === 'smtp_port') config.email.port = parseInt(value, 10);
|
||||||
|
|
@ -260,6 +262,17 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
if (key === 'site_api_domain') config.site.apiDomain = value;
|
if (key === 'site_api_domain') config.site.apiDomain = value;
|
||||||
if (key === 'site_analytics_api_key') config.site.analyticApiKey = value;
|
if (key === 'site_analytics_api_key') config.site.analyticApiKey = value;
|
||||||
if (key === 'site_environment') config.environment = value;
|
if (key === 'site_environment') config.environment = value;
|
||||||
|
if (key === 'site_deployment') config.site.deployment = value;
|
||||||
|
if (key === 'site_redis_host') config.site.redisHost = value;
|
||||||
|
if (key === 'site_redis_tls') config.site.redisTLS = value;
|
||||||
|
if (key === 'site_aws_region') config.site.awsRegion = value;
|
||||||
|
if (key === 'site_aws_s3_bucket') config.site.awsS3Bucket = value;
|
||||||
|
if (key === 'site_cdn_domain') config.site.cdnDomain = value;
|
||||||
|
if (key === 'site_aws_queue_url') config.site.awsQueueUrl = value;
|
||||||
|
if (key === 'site_session_secret') config.site.sessionSecret = value;
|
||||||
|
if (key === 'site_redis_port') config.site.redisPort = value;
|
||||||
|
if (key === 'site_redis_password') config.site.redisPassword = value;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,15 +294,27 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
envContent += '# Server configuration\n';
|
envContent += '# Server configuration\n';
|
||||||
envContent += `PORT=${config.port}\n`;
|
envContent += `PORT=${config.port}\n`;
|
||||||
envContent += `NODE_ENV=${config.nodeEnv}\n`;
|
envContent += `NODE_ENV=${config.nodeEnv}\n`;
|
||||||
envContent += `ENVIRONMENT=${config.environment}\n\n`;
|
envContent += `ENVIRONMENT=${config.environment}\n`;
|
||||||
|
envContent += `DEPLOYMENT_MODE=${config.site.deployment}\n`;
|
||||||
|
envContent += `REDIS_HOST=${config.site.redisHost}\n`;
|
||||||
|
envContent += `REDIS_TLS=${config.site.redisTLS}\n`;
|
||||||
|
envContent += `REDIS_PORT=${config.site.redisPort}\n`;
|
||||||
|
envContent += `REDIS_PASSWORD=${config.site.redisPassword}\n`;
|
||||||
|
envContent += `AWS_REGION=${config.site.awsRegion}\n`;
|
||||||
|
envContent += `S3_BUCKET=${config.site.awsS3Bucket}\n`;
|
||||||
|
envContent += `CDN_DOMAIN=${config.site.cdnDomain}\n`;
|
||||||
|
envContent += `SQS_QUEUE_URL=${config.site.awsQueueUrl}\n`;
|
||||||
|
envContent += `SESSION_SECRET=${config.site.sessionSecret}\n\n`;
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
envContent += '# Database configuration\n';
|
envContent += '# Database configuration\n';
|
||||||
envContent += `DB_HOST=${config.db.host}\n`;
|
envContent += `DB_HOST=${config.db.host}\n`;
|
||||||
|
envContent += `DB_READ_HOST=${config.db.readHost}\n`;
|
||||||
envContent += `DB_USER=${config.db.user}\n`;
|
envContent += `DB_USER=${config.db.user}\n`;
|
||||||
envContent += `DB_PASSWORD=${config.db.password}\n`;
|
envContent += `DB_PASSWORD=${config.db.password}\n`;
|
||||||
envContent += `DB_NAME=${config.db.database}\n`;
|
envContent += `DB_NAME=${config.db.database}\n`;
|
||||||
envContent += `DB_PORT=${config.db.port}\n`;
|
envContent += `DB_PORT=${config.db.port}\n`;
|
||||||
|
envContent += `DB_MAX_CONNECTIONS=${config.db.maxConnections}\n`;
|
||||||
envContent += `POSTGRES_USER=${config.db.user}\n`;
|
envContent += `POSTGRES_USER=${config.db.user}\n`;
|
||||||
envContent += `POSTGRES_PASSWORD=${config.db.password}\n`;
|
envContent += `POSTGRES_PASSWORD=${config.db.password}\n`;
|
||||||
envContent += `POSTGRES_DB=${config.db.database}\n\n`;
|
envContent += `POSTGRES_DB=${config.db.database}\n\n`;
|
||||||
|
|
|
||||||
58
backend/src/services/cacheService.js
Normal file
58
backend/src/services/cacheService.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
class CacheService {
|
||||||
|
constructor() {
|
||||||
|
this.enabled = config.site.deployment === 'cloud' && config.site.redisHost
|
||||||
|
this.client = null;
|
||||||
|
|
||||||
|
if (this.enabled) {
|
||||||
|
this.client = new Redis({
|
||||||
|
host: config.site.redisHost,
|
||||||
|
port: config.site.redisPort,
|
||||||
|
password: config.site.redisPassword,
|
||||||
|
tls: config.site.redisTLS ? {} : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
console.error('Redis Client Error', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key) {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = await this.client.get(key);
|
||||||
|
return value ? JSON.parse(value) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache get error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, value, ttlSeconds = 300) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.setex(key, ttlSeconds, JSON.stringify(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache set error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.del(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cache delete error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a singleton instance
|
||||||
|
const cacheService = new CacheService();
|
||||||
|
module.exports = cacheService;
|
||||||
54
backend/src/services/queueService.js
Normal file
54
backend/src/services/queueService.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// services/queueService.js
|
||||||
|
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
class QueueService {
|
||||||
|
constructor() {
|
||||||
|
this.enabled = config.aws.sqs.enabled;
|
||||||
|
this.sqsClient = null;
|
||||||
|
|
||||||
|
if (this.enabled) {
|
||||||
|
this.sqsClient = new SQSClient({ region: config.aws.region });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(queueUrl, messageBody) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
// In self-hosted mode, execute immediately
|
||||||
|
console.log('Direct execution (no queue):', messageBody);
|
||||||
|
await this._processMessageDirectly(messageBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new SendMessageCommand({
|
||||||
|
QueueUrl: queueUrl,
|
||||||
|
MessageBody: JSON.stringify(messageBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sqsClient.send(command);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Queue send error:', error);
|
||||||
|
// Fallback to direct processing
|
||||||
|
await this._processMessageDirectly(messageBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _processMessageDirectly(messageBody) {
|
||||||
|
// Direct processing for self-hosted mode
|
||||||
|
const emailService = require('./emailService');
|
||||||
|
|
||||||
|
switch(messageBody.type) {
|
||||||
|
case 'LOW_STOCK_ALERT':
|
||||||
|
await emailService.sendLowStockAlert(messageBody);
|
||||||
|
break;
|
||||||
|
case 'ORDER_CONFIRMATION':
|
||||||
|
await emailService.sendOrderConfirmation(messageBody);
|
||||||
|
break;
|
||||||
|
// Add other message types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueService = new QueueService();
|
||||||
|
module.exports = queueService;
|
||||||
83
backend/src/services/storageService.js
Normal file
83
backend/src/services/storageService.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// services/storageService.js
|
||||||
|
const multer = require('multer');
|
||||||
|
const multerS3 = require('multer-s3');
|
||||||
|
const { S3Client } = require('@aws-sdk/client-s3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
constructor() {
|
||||||
|
this.mode = config.site.deployment;
|
||||||
|
this.s3Client = null;
|
||||||
|
|
||||||
|
if (this.mode === 'cloud' && config.site.awsS3Bucket) {
|
||||||
|
this.s3Client = new S3Client({ region: config.site.awsRegion });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getUploadMiddleware() {
|
||||||
|
if (this.mode === 'cloud' && config.site.awsS3Bucket) {
|
||||||
|
// Cloud mode: Use S3
|
||||||
|
return multer({
|
||||||
|
storage: multerS3({
|
||||||
|
s3: this.s3Client,
|
||||||
|
bucket: config.site.awsS3Bucket,
|
||||||
|
acl: 'public-read',
|
||||||
|
key: (req, file, cb) => {
|
||||||
|
const folder = req.path.includes('/product') ? 'products' : 'blog';
|
||||||
|
cb(null, `${folder}/${Date.now()}-${file.originalname}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
fileFilter: this._fileFilter,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Self-hosted mode: Use local storage
|
||||||
|
return multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const uploadDir = path.join(__dirname, '../../public/uploads');
|
||||||
|
const folder = req.path.includes('/product') ? 'products' : 'blog';
|
||||||
|
const finalPath = path.join(uploadDir, folder);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!fs.existsSync(finalPath)) {
|
||||||
|
fs.mkdirSync(finalPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, finalPath);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
cb(null, `${Date.now()}-${file.originalname}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
fileFilter: this._fileFilter,
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileFilter(req, file, cb) {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed!'), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageUrl(path) {
|
||||||
|
if (!path) return null;
|
||||||
|
|
||||||
|
if (this.mode === 'cloud' && config.site.cdnDomain) {
|
||||||
|
// Use CloudFront CDN in cloud mode
|
||||||
|
return `https://${config.site.cdnDomain}${path}`;
|
||||||
|
} else {
|
||||||
|
// Use direct path in self-hosted mode
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageService = new StorageService();
|
||||||
|
module.exports = storageService;
|
||||||
93
backend/src/worker.js
Normal file
93
backend/src/worker.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
const { pool, query } = require('./db');
|
||||||
|
const config = require('./config');
|
||||||
|
const notificationService = require('./services/notificationService');
|
||||||
|
const queueService = require('./services/queueService');
|
||||||
|
const { Consumer } = require('sqs-consumer');
|
||||||
|
const { SQSClient } = require('@aws-sdk/client-sqs');
|
||||||
|
|
||||||
|
console.log('Starting worker process...');
|
||||||
|
console.log(`Environment: ${process.env.ENVIRONMENT || 'beta'}`);
|
||||||
|
console.log(`Deployment mode: ${process.env.DEPLOYMENT_MODE || 'self-hosted'}`);
|
||||||
|
|
||||||
|
// Worker initialization
|
||||||
|
async function initWorker() {
|
||||||
|
try {
|
||||||
|
await pool.connect();
|
||||||
|
console.log('Worker connected to database');
|
||||||
|
|
||||||
|
// Set up processing intervals for database-based notifications
|
||||||
|
const interval = process.env.ENVIRONMENT === 'prod' ? 10 * 60 * 1000 : 2 * 60 * 1000;
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
console.log('Processing low stock notifications...');
|
||||||
|
const processedCount = await notificationService.processLowStockNotifications(pool, query);
|
||||||
|
console.log(`Processed ${processedCount} low stock notifications`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing low stock notifications:', error);
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
// For cloud mode, add SQS message consumption here
|
||||||
|
if (config.aws && config.aws.sqs && config.aws.sqs.enabled && config.aws.sqs.queueUrl) {
|
||||||
|
console.log(`Starting SQS consumer for queue: ${config.aws.sqs.queueUrl}`);
|
||||||
|
|
||||||
|
// Create SQS consumer
|
||||||
|
const consumer = Consumer.create({
|
||||||
|
queueUrl: config.aws.sqs.queueUrl,
|
||||||
|
handleMessage: async (message) => {
|
||||||
|
try {
|
||||||
|
console.log('Processing SQS message:', message.MessageId);
|
||||||
|
const messageBody = JSON.parse(message.Body);
|
||||||
|
|
||||||
|
// Use the direct processing method from queueService
|
||||||
|
await queueService._processMessageDirectly(messageBody);
|
||||||
|
|
||||||
|
console.log('Successfully processed message:', message.MessageId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing message:', message.MessageId, error);
|
||||||
|
throw error; // Rethrow to handle message as failed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sqs: new SQSClient({ region: config.aws.region }),
|
||||||
|
batchSize: 10,
|
||||||
|
visibilityTimeout: 60,
|
||||||
|
waitTimeSeconds: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
consumer.on('error', (err) => {
|
||||||
|
console.error('SQS consumer error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
consumer.on('processing_error', (err) => {
|
||||||
|
console.error('SQS message processing error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
consumer.start();
|
||||||
|
console.log('SQS consumer started');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Worker initialization error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the worker
|
||||||
|
initWorker().catch(err => {
|
||||||
|
console.error('Unhandled worker error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('Worker received SIGTERM, shutting down gracefully');
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('Worker received SIGINT, shutting down gracefully');
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
@ -32,7 +32,18 @@ VALUES
|
||||||
('site_api_domain', NULL, 'site'),
|
('site_api_domain', NULL, 'site'),
|
||||||
('site_protocol', NULL, 'site'),
|
('site_protocol', NULL, 'site'),
|
||||||
('site_environment', NULL, 'site'),
|
('site_environment', NULL, 'site'),
|
||||||
('site_analytics_api_key', NULL, 'site'),
|
('site_deployment', NULL, 'site'),
|
||||||
|
('site_redis_host', NULL, 'site'),
|
||||||
|
('site_redis_tls', NULL, 'site'),
|
||||||
|
('site_aws_region', NULL, 'site'),
|
||||||
|
('site_aws_s3_bucket', NULL, 'site'),
|
||||||
|
('site_cdn_domain', NULL, 'site'),
|
||||||
|
('site_aws_queue_url', NULL, 'site'),
|
||||||
|
('site_read_host', NULL, 'site'),
|
||||||
|
('site_db_max_connections', NULL, 'site'),
|
||||||
|
('site_session_secret', NULL, 'site'),
|
||||||
|
('site_redis_port', NULL, 'site'),
|
||||||
|
('site_redis_password', NULL, 'site'),
|
||||||
|
|
||||||
-- Payment Settings
|
-- Payment Settings
|
||||||
('currency', 'CAD', 'payment'),
|
('currency', 'CAD', 'payment'),
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ services:
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "${PORT:-4000}:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
@ -32,6 +32,9 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
required: false
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
|
@ -55,8 +58,50 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
# Redis service - only active in cloud mode
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
command: ["sh", "-c", "redis-server ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"]
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
profiles:
|
||||||
|
- cloud
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "${REDIS_PASSWORD:+--pass}", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Background worker for SQS and job processing - only active in cloud mode
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: node src/worker.js
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
environment:
|
||||||
|
- WORKER_MODE=true
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
required: false
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
profiles:
|
||||||
|
- cloud
|
||||||
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
|
|
|
||||||
3982
frontend/package-lock.json
generated
Normal file
3982
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,12 +23,14 @@
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"papaparse": "^5.5.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-email-editor": "^1.7.11",
|
"react-email-editor": "^1.7.11",
|
||||||
"react-redux": "^9.0.2",
|
"react-redux": "^9.0.2",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
"recharts": "^2.10.3"
|
"recharts": "^2.10.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
|
|
|
||||||
106
frontend/src/hooks/productAdminHooks.js
Normal file
106
frontend/src/hooks/productAdminHooks.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import apiClient from '@services/api';
|
||||||
|
import { useNotification } from './reduxHooks';
|
||||||
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
export const useCategories = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get('/products/categories/all');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch all available tags
|
||||||
|
export const useTags = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get('/products/tags/all');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch product data if editing
|
||||||
|
export const useProduct = (id, isNewProduct) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['product', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get(`/products/${id}`);
|
||||||
|
return response.data[0];
|
||||||
|
},
|
||||||
|
enabled: !isNewProduct
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create product mutation
|
||||||
|
export const useCreateProduct = () => {
|
||||||
|
const notification = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (productData) => {
|
||||||
|
return await apiClient.post('/admin/products', productData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||||
|
notification.showNotification('Product created successfully', 'success');
|
||||||
|
// Redirect after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/admin/products');
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
`Failed to create product: ${error.message}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update product mutation
|
||||||
|
export const useUpdateProduct = (id) => {
|
||||||
|
const notification = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, productData }) => {
|
||||||
|
return await apiClient.put(`/admin/products/${id}`, productData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product', id] });
|
||||||
|
notification.showNotification('Product updated successfully', 'success');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
`Failed to update product: ${error.message}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save stock notification settings
|
||||||
|
export const useSaveStockNotification = (id) => {
|
||||||
|
const notification = useNotification();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (notificationData) => {
|
||||||
|
return await apiClient.post(`/admin/products/${id}/stock-notification`, notificationData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
notification.showNotification('Stock notification settings saved!', 'success');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
`Failed to save notification settings: ${error.message}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
@ -31,6 +31,7 @@ import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||||
import ImageUploader from '@components/ImageUploader';
|
import ImageUploader from '@components/ImageUploader';
|
||||||
import apiClient from '@services/api';
|
import apiClient from '@services/api';
|
||||||
import { useAuth } from '@hooks/reduxHooks';
|
import { useAuth } from '@hooks/reduxHooks';
|
||||||
|
import { useCategories, useTags, useProduct, useCreateProduct, useUpdateProduct, useSaveStockNotification } from '@hooks/productAdminHooks';
|
||||||
|
|
||||||
const ProductEditPage = () => {
|
const ProductEditPage = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
@ -75,106 +76,26 @@ const ProductEditPage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categories, isLoading: categoriesLoading } = useQuery({
|
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||||
queryKey: ['categories'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get('/products/categories/all');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch all available tags
|
// Fetch all available tags
|
||||||
const { data: allTags, isLoading: tagsLoading } = useQuery({
|
const { data: allTags, isLoading: tagsLoading } = useTags();
|
||||||
queryKey: ['tags'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get('/products/tags/all');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch product data if editing
|
// Fetch product data if editing
|
||||||
const {
|
const {
|
||||||
data: product,
|
data: product,
|
||||||
isLoading: productLoading,
|
isLoading: productLoading,
|
||||||
error: productError
|
error: productError
|
||||||
} = useQuery({
|
} = useProduct(id === 'new' ? null : id, isNewProduct);
|
||||||
queryKey: ['product', id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await apiClient.get(`/products/${id}`);
|
|
||||||
return response.data[0];
|
|
||||||
},
|
|
||||||
enabled: !isNewProduct
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create product mutation
|
// Create product mutation
|
||||||
const createProduct = useMutation({
|
const createProduct = useCreateProduct();
|
||||||
mutationFn: async (productData) => {
|
|
||||||
return await apiClient.post('/admin/products', productData);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
message: 'Product created successfully!',
|
|
||||||
severity: 'success'
|
|
||||||
});
|
|
||||||
// Redirect after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/admin/products');
|
|
||||||
}, 1500);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
message: `Failed to create product: ${error.message}`,
|
|
||||||
severity: 'error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update product mutation
|
// Update product mutation
|
||||||
const updateProduct = useMutation({
|
const updateProduct = useUpdateProduct(id === 'new' ? null : id);
|
||||||
mutationFn: async ({ id, productData }) => {
|
|
||||||
return await apiClient.put(`/admin/products/${id}`, productData);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['product', id] });
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
message: 'Product updated successfully!',
|
|
||||||
severity: 'success'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
message: `Failed to update product: ${error.message}`,
|
|
||||||
severity: 'error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save stock notification settings
|
// Save stock notification settings
|
||||||
const saveStockNotification = useMutation({
|
const saveStockNotification = useSaveStockNotification(id === 'new' ? null : id);
|
||||||
mutationFn: async (notificationData) => {
|
|
||||||
return await apiClient.post(`/admin/products/${id}/stock-notification`, notificationData);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
message: 'Stock notification settings saved!',
|
|
||||||
severity: 'success'
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setNotification({
|
|
||||||
open: true,
|
|
||||||
message: `Failed to save notification settings: ${error.message}`,
|
|
||||||
severity: 'error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle form changes
|
// Handle form changes
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
|
|
@ -323,35 +244,34 @@ const ProductEditPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add notification data if enabled
|
// Add notification data if enabled
|
||||||
if (notificationEnabled && !isNewProduct) {
|
|
||||||
|
if (notificationEnabled) {
|
||||||
productData.stockNotification = {
|
productData.stockNotification = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
email: notificationEmail,
|
email: notificationEmail,
|
||||||
threshold: stockThreshold
|
threshold: stockThreshold
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewProduct) {
|
if (isNewProduct) {
|
||||||
createProduct.mutate(productData);
|
createProduct.mutate(productData);
|
||||||
} else {
|
} else {
|
||||||
updateProduct.mutate({ id, productData });
|
updateProduct.mutate({ id, productData });
|
||||||
|
}
|
||||||
// Save notification settings separately
|
// Save notification settings separately
|
||||||
if (notificationEnabled) {
|
// if (notificationEnabled) {
|
||||||
saveStockNotification.mutate({
|
// saveStockNotification.mutate({
|
||||||
enabled: true,
|
// enabled: true,
|
||||||
email: notificationEmail,
|
// email: notificationEmail,
|
||||||
threshold: stockThreshold
|
// threshold: stockThreshold
|
||||||
});
|
// });
|
||||||
} else {
|
// } else {
|
||||||
// Disable notifications if checkbox is unchecked
|
// // Disable notifications if checkbox is unchecked
|
||||||
saveStockNotification.mutate({
|
// saveStockNotification.mutate({
|
||||||
enabled: false,
|
// enabled: false,
|
||||||
email: '',
|
// email: '',
|
||||||
threshold: 0
|
// threshold: 0
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle notification close
|
// Handle notification close
|
||||||
|
|
@ -545,7 +465,7 @@ const ProductEditPage = () => {
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Stock Notification Section */}
|
{/* Stock Notification Section */}
|
||||||
{!isNewProduct && (
|
|
||||||
<>
|
<>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
@ -616,7 +536,6 @@ const ProductEditPage = () => {
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,27 @@ import {
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogTitle
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Edit as EditIcon,
|
Edit as EditIcon,
|
||||||
Delete as DeleteIcon,
|
Delete as DeleteIcon,
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
Clear as ClearIcon
|
Clear as ClearIcon,
|
||||||
|
FileDownload as DownloadIcon,
|
||||||
|
Upload as UploadIcon,
|
||||||
|
CloudUpload as CloudUploadIcon,
|
||||||
|
CheckCircleOutline as CheckCircleIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import apiClient from '../../services/api';
|
import apiClient from '../../services/api';
|
||||||
import ProductImage from '../../components/ProductImage';
|
import ProductImage from '../../components/ProductImage';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
|
||||||
const AdminProductsPage = () => {
|
const AdminProductsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -44,6 +52,18 @@ const AdminProductsPage = () => {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [productToDelete, setProductToDelete] = useState(null);
|
const [productToDelete, setProductToDelete] = useState(null);
|
||||||
|
|
||||||
|
// New states for upload functionality
|
||||||
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||||
|
const [uploadFile, setUploadFile] = useState(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadError, setUploadError] = useState(null);
|
||||||
|
const [parsedProducts, setParsedProducts] = useState([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||||
|
const [uploadResults, setUploadResults] = useState(null);
|
||||||
|
|
||||||
|
const fileInputRef = React.useRef(null);
|
||||||
|
|
||||||
// Fetch products
|
// Fetch products
|
||||||
const {
|
const {
|
||||||
data: products,
|
data: products,
|
||||||
|
|
@ -76,6 +96,23 @@ const AdminProductsPage = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk upload mutation
|
||||||
|
const bulkUploadProducts = useMutation({
|
||||||
|
mutationFn: async (productData) => {
|
||||||
|
return await apiClient.post('/admin/products', { products: productData });
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||||
|
setUploadSuccess(true);
|
||||||
|
setUploadResults(data.data);
|
||||||
|
setIsUploading(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setUploadError(error.message || 'Failed to upload products');
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle search change
|
// Handle search change
|
||||||
const handleSearchChange = (event) => {
|
const handleSearchChange = (event) => {
|
||||||
setSearch(event.target.value);
|
setSearch(event.target.value);
|
||||||
|
|
@ -123,6 +160,336 @@ const AdminProductsPage = () => {
|
||||||
navigate(`/admin/products/${productId}`);
|
navigate(`/admin/products/${productId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle download template
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
// Create template headers
|
||||||
|
const headers = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'categoryName',
|
||||||
|
'price',
|
||||||
|
'stockQuantity',
|
||||||
|
'weightGrams',
|
||||||
|
'lengthCm',
|
||||||
|
'widthCm',
|
||||||
|
'heightCm',
|
||||||
|
'origin',
|
||||||
|
'age',
|
||||||
|
'materialType',
|
||||||
|
'color',
|
||||||
|
'stockNotification_enabled',
|
||||||
|
'stockNotification_email',
|
||||||
|
'stockNotification_threshold',
|
||||||
|
'tags' // Comma-separated tags
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create example data row
|
||||||
|
const exampleRow = [
|
||||||
|
'Example Product',
|
||||||
|
'This is a product description',
|
||||||
|
'Rock', // Valid category name
|
||||||
|
'19.99',
|
||||||
|
'10',
|
||||||
|
'500',
|
||||||
|
'15',
|
||||||
|
'10',
|
||||||
|
'5',
|
||||||
|
'Brazil',
|
||||||
|
'Recent',
|
||||||
|
'Quartz',
|
||||||
|
'Purple',
|
||||||
|
'FALSE', // stockNotification_enabled
|
||||||
|
'email@example.com', // stockNotification_email
|
||||||
|
'5', // stockNotification_threshold
|
||||||
|
'Rare,Polished' // Comma-separated tags
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create workbook
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products Template');
|
||||||
|
|
||||||
|
// Add column widths for better readability
|
||||||
|
const colWidths = [];
|
||||||
|
headers.forEach(() => colWidths.push({ wch: 15 }));
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
// Generate Excel file
|
||||||
|
XLSX.writeFile(workbook, 'products_upload_template.xlsx');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle export all products
|
||||||
|
const handleExportAllProducts = () => {
|
||||||
|
if (!products || products.length === 0) {
|
||||||
|
// Show notification if no products to export
|
||||||
|
alert("No products to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create headers (same as template, for consistency)
|
||||||
|
const headers = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'categoryName',
|
||||||
|
'price',
|
||||||
|
'stockQuantity',
|
||||||
|
'weightGrams',
|
||||||
|
'lengthCm',
|
||||||
|
'widthCm',
|
||||||
|
'heightCm',
|
||||||
|
'origin',
|
||||||
|
'age',
|
||||||
|
'materialType',
|
||||||
|
'color',
|
||||||
|
'stockNotification_enabled',
|
||||||
|
'stockNotification_email',
|
||||||
|
'stockNotification_threshold',
|
||||||
|
'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Format the product data for Excel
|
||||||
|
const formattedData = products.map(product => {
|
||||||
|
// Extract stock notification values if they exist
|
||||||
|
let stockNotificationEnabled = 'FALSE';
|
||||||
|
let stockNotificationEmail = '';
|
||||||
|
let stockNotificationThreshold = '';
|
||||||
|
|
||||||
|
if (product.stock_notification) {
|
||||||
|
try {
|
||||||
|
const notification =
|
||||||
|
typeof product.stock_notification === 'string'
|
||||||
|
? JSON.parse(product.stock_notification)
|
||||||
|
: product.stock_notification;
|
||||||
|
|
||||||
|
stockNotificationEnabled = notification.enabled ? 'TRUE' : 'FALSE';
|
||||||
|
stockNotificationEmail = notification.email || '';
|
||||||
|
stockNotificationThreshold = notification.threshold || '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing stock notification:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert tags array to comma-separated string if it exists
|
||||||
|
const tagsString = Array.isArray(product.tags)
|
||||||
|
? product.tags.join(',')
|
||||||
|
: (product.tags || '');
|
||||||
|
|
||||||
|
// Return array in the same order as headers
|
||||||
|
return [
|
||||||
|
product.name || '',
|
||||||
|
product.description || '',
|
||||||
|
product.category_name || '',
|
||||||
|
product.price || '',
|
||||||
|
product.stock_quantity || '',
|
||||||
|
product.weight_grams || '',
|
||||||
|
product.length_cm || '',
|
||||||
|
product.width_cm || '',
|
||||||
|
product.height_cm || '',
|
||||||
|
product.origin || '',
|
||||||
|
product.age || '',
|
||||||
|
product.material_type || '',
|
||||||
|
product.color || '',
|
||||||
|
stockNotificationEnabled,
|
||||||
|
stockNotificationEmail,
|
||||||
|
stockNotificationThreshold,
|
||||||
|
tagsString
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add headers as first row
|
||||||
|
const data = [headers, ...formattedData];
|
||||||
|
|
||||||
|
// Create Excel workbook and add the data
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products');
|
||||||
|
|
||||||
|
// Add column widths for better readability
|
||||||
|
const colWidths = [];
|
||||||
|
headers.forEach(() => colWidths.push({ wch: 15 }));
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
// Generate filename with current date
|
||||||
|
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||||
|
const filename = `products_export_${date}.xlsx`;
|
||||||
|
|
||||||
|
// Write Excel file and trigger download
|
||||||
|
XLSX.writeFile(workbook, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle open upload dialog
|
||||||
|
const handleOpenUploadDialog = () => {
|
||||||
|
resetUploadState();
|
||||||
|
setUploadDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle close upload dialog
|
||||||
|
const handleCloseUploadDialog = () => {
|
||||||
|
resetUploadState();
|
||||||
|
setUploadDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset upload states
|
||||||
|
const resetUploadState = () => {
|
||||||
|
setUploadFile(null);
|
||||||
|
setUploadProgress(0);
|
||||||
|
setUploadError(null);
|
||||||
|
setParsedProducts([]);
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadSuccess(false);
|
||||||
|
setUploadResults(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
setUploadError(null);
|
||||||
|
setUploadSuccess(false);
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadFile(file);
|
||||||
|
|
||||||
|
// Determine file type and parse accordingly
|
||||||
|
const fileExtension = file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (fileExtension === 'csv') {
|
||||||
|
parseCsvFile(file);
|
||||||
|
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
||||||
|
parseExcelFile(file);
|
||||||
|
} else {
|
||||||
|
setUploadError('Unsupported file format. Please upload a CSV or Excel file.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse CSV file
|
||||||
|
const parseCsvFile = (file) => {
|
||||||
|
Papa.parse(file, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
complete: (results) => {
|
||||||
|
if (results.errors.length > 0) {
|
||||||
|
setUploadError(`Error parsing CSV: ${results.errors[0].message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedProducts = formatParsedProducts(results.data);
|
||||||
|
setParsedProducts(formattedProducts);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
setUploadError(`Error parsing CSV: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse Excel file
|
||||||
|
const parseExcelFile = (file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(e.target.result);
|
||||||
|
const workbook = XLSX.read(data, { type: 'array' });
|
||||||
|
|
||||||
|
// Get first sheet
|
||||||
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[firstSheetName];
|
||||||
|
|
||||||
|
// Convert to JSON
|
||||||
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: null });
|
||||||
|
|
||||||
|
const formattedProducts = formatParsedProducts(jsonData);
|
||||||
|
setParsedProducts(formattedProducts);
|
||||||
|
} catch (error) {
|
||||||
|
setUploadError(`Error parsing Excel file: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
setUploadError('Error reading file');
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format parsed products to match API expectations
|
||||||
|
const formatParsedProducts = (rawProducts) => {
|
||||||
|
return rawProducts.map(product => {
|
||||||
|
// Extract stock notification fields and combine them
|
||||||
|
const stockNotification = {
|
||||||
|
enabled: product.stockNotification_enabled === 'TRUE' || product.stockNotification_enabled === true,
|
||||||
|
email: product.stockNotification_email || null,
|
||||||
|
threshold: product.stockNotification_threshold ? parseInt(product.stockNotification_threshold) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process tags (convert from comma-separated string to array)
|
||||||
|
let tags = [];
|
||||||
|
if (product.tags) {
|
||||||
|
if (typeof product.tags === 'string') {
|
||||||
|
tags = product.tags.split(',').map(tag => tag.trim()).filter(tag => tag);
|
||||||
|
} else if (Array.isArray(product.tags)) {
|
||||||
|
tags = product.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert numeric values
|
||||||
|
const price = product.price ? parseFloat(product.price) : null;
|
||||||
|
const stockQuantity = product.stockQuantity ? parseInt(product.stockQuantity) : null;
|
||||||
|
const weightGrams = product.weightGrams ? parseFloat(product.weightGrams) : null;
|
||||||
|
const lengthCm = product.lengthCm ? parseFloat(product.lengthCm) : null;
|
||||||
|
const widthCm = product.widthCm ? parseFloat(product.widthCm) : null;
|
||||||
|
const heightCm = product.heightCm ? parseFloat(product.heightCm) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: product.name || '',
|
||||||
|
description: product.description || '',
|
||||||
|
categoryName: product.categoryName || '',
|
||||||
|
price: price,
|
||||||
|
stockQuantity: stockQuantity,
|
||||||
|
weightGrams: weightGrams,
|
||||||
|
lengthCm: lengthCm,
|
||||||
|
widthCm: widthCm,
|
||||||
|
heightCm: heightCm,
|
||||||
|
origin: product.origin || null,
|
||||||
|
age: product.age || null,
|
||||||
|
materialType: product.materialType || null,
|
||||||
|
color: product.color || null,
|
||||||
|
stockNotification: stockNotification,
|
||||||
|
tags: tags,
|
||||||
|
images: [] // No images in bulk upload template
|
||||||
|
};
|
||||||
|
}).filter(product =>
|
||||||
|
// Filter out products with missing required fields
|
||||||
|
product.name &&
|
||||||
|
product.description &&
|
||||||
|
product.categoryName &&
|
||||||
|
product.price !== null &&
|
||||||
|
product.stockQuantity !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit bulk upload
|
||||||
|
const handleSubmitUpload = () => {
|
||||||
|
if (parsedProducts.length === 0) {
|
||||||
|
setUploadError('No valid products found in the file. Please check your data.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadError(null);
|
||||||
|
|
||||||
|
// Submit products in batches of 20 to respect connection limits
|
||||||
|
const batchSize = 20;
|
||||||
|
const batches = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < parsedProducts.length; i += batchSize) {
|
||||||
|
const batch = parsedProducts.slice(i, i + batchSize);
|
||||||
|
batches.push(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process first batch
|
||||||
|
bulkUploadProducts.mutate(parsedProducts);
|
||||||
|
};
|
||||||
|
|
||||||
// Filter and paginate products
|
// Filter and paginate products
|
||||||
const filteredProducts = products || [];
|
const filteredProducts = products || [];
|
||||||
const paginatedProducts = filteredProducts.slice(
|
const paginatedProducts = filteredProducts.slice(
|
||||||
|
|
@ -155,6 +522,38 @@ const AdminProductsPage = () => {
|
||||||
Products
|
Products
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<Tooltip title="Download a template for bulk product uploads">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
>
|
||||||
|
Download Template
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Export all current products to Excel">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={handleExportAllProducts}
|
||||||
|
>
|
||||||
|
Export All Products
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Upload products in bulk from a CSV or Excel file">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<UploadIcon />}
|
||||||
|
onClick={handleOpenUploadDialog}
|
||||||
|
>
|
||||||
|
Upload Bulk Products
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -164,6 +563,7 @@ const AdminProductsPage = () => {
|
||||||
>
|
>
|
||||||
Add Product
|
Add Product
|
||||||
</Button>
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|
@ -301,6 +701,190 @@ const AdminProductsPage = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Upload Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={uploadDialogOpen}
|
||||||
|
onClose={handleCloseUploadDialog}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Upload Products</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{!uploadSuccess ? (
|
||||||
|
<>
|
||||||
|
<DialogContentText sx={{ mb: 2 }}>
|
||||||
|
Upload a CSV or Excel file containing your product data. Make sure the file follows the required format.
|
||||||
|
</DialogContentText>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag and drop area */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: theme => uploadFile ? theme.palette.success.main : theme.palette.divider,
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 3,
|
||||||
|
mb: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: theme => uploadFile ? 'rgba(76, 175, 80, 0.08)' : 'transparent',
|
||||||
|
cursor: isUploading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: theme => !isUploading && theme.palette.primary.main,
|
||||||
|
backgroundColor: theme => !isUploading && 'rgba(25, 118, 210, 0.04)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
component="label"
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isUploading) return;
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length) {
|
||||||
|
// Update the file input to reflect the dragged file
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
// Create a DataTransfer object
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(files[0]);
|
||||||
|
fileInputRef.current.files = dataTransfer.files;
|
||||||
|
|
||||||
|
// Trigger the change event handler
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
fileInputRef.current.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isUploading) {
|
||||||
|
e.currentTarget.style.borderColor = '#1976d2';
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(25, 118, 210, 0.04)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!uploadFile) {
|
||||||
|
e.currentTarget.style.borderColor = '';
|
||||||
|
e.currentTarget.style.backgroundColor = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv, .xlsx, .xls"
|
||||||
|
hidden
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{uploadFile ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleIcon sx={{ color: 'success.main', fontSize: 48, mb: 1 }} />
|
||||||
|
<Typography variant="body1" align="center" gutterBottom>
|
||||||
|
File selected: {uploadFile.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
Drag and drop a different file or click to change
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CloudUploadIcon sx={{ fontSize: 48, color: 'action.active', mb: 1 }} />
|
||||||
|
<Typography variant="body1" align="center" gutterBottom>
|
||||||
|
Drag and drop your CSV or Excel file here
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
or click to select a file
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{parsedProducts.length > 0 && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2, width: '100%' }}>
|
||||||
|
Found {parsedProducts.length} valid products in the file.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
|
||||||
|
{uploadError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ mt: 2, mb: 2 }}>
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
Upload successful!
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{uploadResults && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
Upload Results:
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2">
|
||||||
|
{uploadResults.success?.length || 0} products created successfully
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{uploadResults.errs?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
|
||||||
|
{uploadResults.errs.length} products failed to create
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ mt: 1, p: 1, maxHeight: 150, overflow: 'auto' }}>
|
||||||
|
{uploadResults.errs.map((err, index) => (
|
||||||
|
<Typography key={index} variant="caption" display="block" color="error">
|
||||||
|
Error: {err.message}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{!uploadSuccess ? (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleCloseUploadDialog} color="primary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitUpload}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isUploading || parsedProducts.length === 0}
|
||||||
|
startIcon={isUploading && <CircularProgress size={20} color="inherit" />}
|
||||||
|
>
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload Products'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleCloseUploadDialog} color="primary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
23
start.sh
Executable file
23
start.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# start.sh
|
||||||
|
|
||||||
|
MODE=${1:-self-hosted}
|
||||||
|
|
||||||
|
if [ "$MODE" == "cloud" ]; then
|
||||||
|
echo "Starting in CLOUD mode"
|
||||||
|
# Make sure .env has cloud settings
|
||||||
|
grep -q "DEPLOYMENT_MODE=cloud" ./backend/.env || \
|
||||||
|
sed -i 's/DEPLOYMENT_MODE=.*/DEPLOYMENT_MODE=cloud/' ./backend/.env
|
||||||
|
|
||||||
|
# Start with cloud profile
|
||||||
|
docker compose --profile cloud up -d --build
|
||||||
|
else
|
||||||
|
echo "Starting in SELF-HOSTED mode"
|
||||||
|
# Make sure .env has self-hosted settings
|
||||||
|
grep -q "DEPLOYMENT_MODE=self-hosted" ./backend/.env || \
|
||||||
|
sed -i 's/DEPLOYMENT_MODE=.*/DEPLOYMENT_MODE=self-hosted/' ./backend/.env
|
||||||
|
|
||||||
|
# Start without extra services
|
||||||
|
docker compose up -d --build
|
||||||
|
fi
|
||||||
|
echo "Deployment complete in $MODE mode"
|
||||||
Loading…
Reference in a new issue