Compare commits
No commits in common. "a06863562cbbd48ed427ee0e1576e48af1f6d525" and "6bb9bd40ddb31457dc29a352dd627924b1e2ffe7" have entirely different histories.
a06863562c
...
6bb9bd40dd
23 changed files with 740 additions and 5662 deletions
337
backend/package-lock.json
generated
Normal file
337
backend/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
{
|
||||||
|
"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,18 +9,14 @@
|
||||||
"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.
|
After Width: | Height: | Size: 684 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 684 KiB |
|
|
@ -3,7 +3,6 @@ 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,
|
||||||
|
|
@ -16,9 +15,7 @@ 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
|
||||||
|
|
@ -68,16 +65,6 @@ 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 || ''
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -159,6 +146,7 @@ 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) {
|
||||||
|
|
@ -166,33 +154,6 @@ 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,73 +1,25 @@
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const config = require('../config')
|
const config = require('../config')
|
||||||
|
|
||||||
let writePool, readPool;
|
// Create a pool instance
|
||||||
|
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create read pool only in cloud mode
|
// Helper function for running queries
|
||||||
if (config.site.deployment === 'cloud') {
|
const query = async (text, params) => {
|
||||||
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;
|
||||||
|
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
// Old Query function
|
query,
|
||||||
// const query = async (text, params) => {
|
pool
|
||||||
// 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,7 +13,6 @@ 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');
|
||||||
|
|
@ -60,46 +59,45 @@ 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 () => {
|
||||||
|
|
@ -199,16 +197,11 @@ 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: storageService.getImageUrl(imagePath)
|
imagePath: `/uploads/${req.file.filename}`
|
||||||
});
|
});
|
||||||
// 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) => {
|
||||||
|
|
@ -256,22 +249,13 @@ 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 = req.file.path ?
|
const imagePath = `/uploads/products/${req.file.filename}`;
|
||||||
`/uploads/products/${req.file.filename}` :
|
|
||||||
req.file.location;
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
imagePath: storageService.getImageUrl(imagePath),
|
imagePath,
|
||||||
filename: req.file.filename || path.basename(req.file.location)
|
filename: req.file.filename
|
||||||
});
|
});
|
||||||
// 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)
|
||||||
|
|
@ -285,30 +269,15 @@ 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 => ({
|
||||||
const imagePath = file.path ?
|
imagePath: `/uploads/products/${file.filename}`,
|
||||||
`/uploads/products/${file.filename}` :
|
filename: 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)
|
||||||
|
|
@ -324,26 +293,18 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.site.deployment === 'cloud' && config.site.awsS3Bucket) {
|
const filePath = path.join(__dirname, '../public/uploads/products', filename);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: true,
|
error: true,
|
||||||
message: 'Image not found'
|
message: 'Image not found'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the file
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -385,7 +346,6 @@ 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,6 +68,7 @@ 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,19 +6,17 @@ 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
|
||||||
|
|
@ -514,45 +512,16 @@ 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 - only for local storage
|
// Clean up temp file
|
||||||
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);
|
||||||
|
|
@ -714,10 +683,6 @@ 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,79 +9,150 @@ 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
|
const {
|
||||||
let products = [];
|
name,
|
||||||
if (req.body.products) {
|
description,
|
||||||
products = req.body.products;
|
categoryName,
|
||||||
} else {
|
price,
|
||||||
const {
|
stockQuantity,
|
||||||
name,
|
weightGrams,
|
||||||
description,
|
lengthCm,
|
||||||
categoryName,
|
widthCm,
|
||||||
price,
|
heightCm,
|
||||||
stockQuantity,
|
origin,
|
||||||
weightGrams,
|
age,
|
||||||
lengthCm,
|
materialType,
|
||||||
widthCm,
|
color,
|
||||||
heightCm,
|
images,
|
||||||
stockNotification,
|
tags
|
||||||
origin,
|
} = req.body;
|
||||||
age,
|
|
||||||
materialType,
|
|
||||||
color,
|
|
||||||
images,
|
|
||||||
tags
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
products.push({
|
// Validate required fields
|
||||||
name,
|
if (!name || !description || !categoryName || !price || !stockQuantity) {
|
||||||
description,
|
return res.status(400).json({
|
||||||
categoryName,
|
error: true,
|
||||||
price,
|
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory'
|
||||||
stockQuantity,
|
|
||||||
weightGrams,
|
|
||||||
lengthCm,
|
|
||||||
widthCm,
|
|
||||||
heightCm,
|
|
||||||
stockNotification,
|
|
||||||
origin,
|
|
||||||
age,
|
|
||||||
materialType,
|
|
||||||
color,
|
|
||||||
images: images || [],
|
|
||||||
tags
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare promises array for concurrent execution
|
// Begin transaction
|
||||||
const productPromises = products.map(product => createProduct(product, pool));
|
const client = await pool.connect();
|
||||||
|
|
||||||
// Execute all product creation promises concurrently
|
try {
|
||||||
const results = await Promise.all(productPromises);
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Separate successes and errors
|
// Get category ID by name
|
||||||
const success = results.filter(result => !result.error).map(result => ({
|
const categoryResult = await client.query(
|
||||||
message: 'Product created successfully',
|
'SELECT id FROM product_categories WHERE name = $1',
|
||||||
product: result.product,
|
[categoryName]
|
||||||
code: 201
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
const errs = results.filter(result => result.error);
|
if (categoryResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: `Category "${categoryName}" not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json({
|
const categoryId = categoryResult.rows[0].id;
|
||||||
message: errs.length > 0 ?
|
|
||||||
(success.length > 0 ? 'Some Products failed to create, check errors array' : "Failed to create Products") :
|
// Create product
|
||||||
'Products created successfully',
|
const productId = uuidv4();
|
||||||
success,
|
await client.query(
|
||||||
errs
|
`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({
|
||||||
|
message: 'Product created successfully',
|
||||||
|
product: product.rows[0]
|
||||||
|
});
|
||||||
|
} 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;
|
||||||
|
|
@ -93,7 +164,6 @@ 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(
|
||||||
|
|
@ -116,22 +186,23 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update product with notification settings
|
// Update product with notification settings
|
||||||
const result = await addThresholdNotification(id, enabled, email, threshold, client);
|
const result = await query(
|
||||||
await client.query('COMMIT');
|
`UPDATE products
|
||||||
|
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 {
|
||||||
|
|
@ -144,7 +215,6 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
stockQuantity,
|
stockQuantity,
|
||||||
weightGrams,
|
weightGrams,
|
||||||
lengthCm,
|
lengthCm,
|
||||||
stockNotification,
|
|
||||||
widthCm,
|
widthCm,
|
||||||
heightCm,
|
heightCm,
|
||||||
origin,
|
origin,
|
||||||
|
|
@ -275,10 +345,6 @@ 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
|
||||||
|
|
@ -420,178 +486,3 @@ 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,11 +224,9 @@ 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);
|
||||||
|
|
@ -262,17 +260,6 @@ 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;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,27 +281,15 @@ 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`;
|
envContent += `ENVIRONMENT=${config.environment}\n\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`;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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,18 +32,7 @@ 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_deployment', NULL, 'site'),
|
('site_analytics_api_key', 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:
|
||||||
- "${PORT:-4000}:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
@ -32,9 +32,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -58,50 +55,8 @@ 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
3982
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -23,14 +23,12 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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,7 +31,6 @@ 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();
|
||||||
|
|
@ -76,26 +75,106 @@ const ProductEditPage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
const { data: categories, isLoading: categoriesLoading } = useQuery({
|
||||||
|
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 } = useTags();
|
const { data: allTags, isLoading: tagsLoading } = useQuery({
|
||||||
|
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
|
||||||
} = useProduct(id === 'new' ? null : id, isNewProduct);
|
} = useQuery({
|
||||||
|
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 = useCreateProduct();
|
const createProduct = useMutation({
|
||||||
|
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 = useUpdateProduct(id === 'new' ? null : id);
|
const updateProduct = useMutation({
|
||||||
|
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 = useSaveStockNotification(id === 'new' ? null : id);
|
const saveStockNotification = useMutation({
|
||||||
|
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) => {
|
||||||
|
|
@ -244,34 +323,35 @@ 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
|
||||||
|
if (notificationEnabled) {
|
||||||
|
saveStockNotification.mutate({
|
||||||
|
enabled: true,
|
||||||
|
email: notificationEmail,
|
||||||
|
threshold: stockThreshold
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Disable notifications if checkbox is unchecked
|
||||||
|
saveStockNotification.mutate({
|
||||||
|
enabled: false,
|
||||||
|
email: '',
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Save notification settings separately
|
|
||||||
// if (notificationEnabled) {
|
|
||||||
// saveStockNotification.mutate({
|
|
||||||
// enabled: true,
|
|
||||||
// email: notificationEmail,
|
|
||||||
// threshold: stockThreshold
|
|
||||||
// });
|
|
||||||
// } else {
|
|
||||||
// // Disable notifications if checkbox is unchecked
|
|
||||||
// saveStockNotification.mutate({
|
|
||||||
// enabled: false,
|
|
||||||
// email: '',
|
|
||||||
// threshold: 0
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle notification close
|
// Handle notification close
|
||||||
|
|
@ -465,77 +545,78 @@ const ProductEditPage = () => {
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Stock Notification Section */}
|
{/* Stock Notification Section */}
|
||||||
|
{!isNewProduct && (
|
||||||
|
<>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Stock Level Notifications
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<>
|
<Grid item xs={12}>
|
||||||
<Grid item xs={12}>
|
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<CardContent>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
Stock Level Notifications
|
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
|
||||||
</Typography>
|
<Typography variant="subtitle1">
|
||||||
</Grid>
|
Get notified when stock is running low
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Grid item xs={12}>
|
<FormControlLabel
|
||||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
control={
|
||||||
<CardContent>
|
<Checkbox
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
checked={notificationEnabled}
|
||||||
<NotificationsActiveIcon color="primary" sx={{ mr: 1 }} />
|
onChange={handleNotificationToggle}
|
||||||
<Typography variant="subtitle1">
|
name="notificationEnabled"
|
||||||
Get notified when stock is running low
|
color="primary"
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={notificationEnabled}
|
|
||||||
onChange={handleNotificationToggle}
|
|
||||||
name="notificationEnabled"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Enable stock level notifications"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{notificationEnabled && (
|
|
||||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
|
||||||
<Grid item xs={12} md={6}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Notification Email"
|
|
||||||
name="notificationEmail"
|
|
||||||
type="email"
|
|
||||||
value={notificationEmail}
|
|
||||||
onChange={handleNotificationEmailChange}
|
|
||||||
error={!!errors.notificationEmail}
|
|
||||||
helperText={errors.notificationEmail}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
label="Enable stock level notifications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notificationEnabled && (
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Notification Email"
|
||||||
|
name="notificationEmail"
|
||||||
|
type="email"
|
||||||
|
value={notificationEmail}
|
||||||
|
onChange={handleNotificationEmailChange}
|
||||||
|
error={!!errors.notificationEmail}
|
||||||
|
helperText={errors.notificationEmail}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Stock Threshold"
|
||||||
|
name="stockThreshold"
|
||||||
|
type="number"
|
||||||
|
value={stockThreshold}
|
||||||
|
onChange={handleStockThresholdChange}
|
||||||
|
error={!!errors.stockThreshold}
|
||||||
|
helperText={errors.stockThreshold || "You'll be notified when stock falls below this number"}
|
||||||
|
required
|
||||||
|
InputProps={{
|
||||||
|
inputProps: {
|
||||||
|
min: 1,
|
||||||
|
max: formData.stockQuantity ? parseInt(formData.stockQuantity) : 999
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={6}>
|
)}
|
||||||
<TextField
|
</CardContent>
|
||||||
fullWidth
|
</Card>
|
||||||
label="Stock Threshold"
|
</Grid>
|
||||||
name="stockThreshold"
|
</>
|
||||||
type="number"
|
)}
|
||||||
value={stockThreshold}
|
|
||||||
onChange={handleStockThresholdChange}
|
|
||||||
error={!!errors.stockThreshold}
|
|
||||||
helperText={errors.stockThreshold || "You'll be notified when stock falls below this number"}
|
|
||||||
required
|
|
||||||
InputProps={{
|
|
||||||
inputProps: {
|
|
||||||
min: 1,
|
|
||||||
max: formData.stockQuantity ? parseInt(formData.stockQuantity) : 999
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
|
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Divider sx={{ my: 2 }} />
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
|
||||||
|
|
@ -21,27 +21,19 @@ 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();
|
||||||
|
|
@ -52,18 +44,6 @@ 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,
|
||||||
|
|
@ -96,23 +76,6 @@ 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);
|
||||||
|
|
@ -160,336 +123,6 @@ 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(
|
||||||
|
|
@ -522,48 +155,15 @@ const AdminProductsPage = () => {
|
||||||
Products
|
Products
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack direction="row" spacing={2}>
|
<Button
|
||||||
<Tooltip title="Download a template for bulk product uploads">
|
variant="contained"
|
||||||
<Button
|
color="primary"
|
||||||
variant="outlined"
|
startIcon={<AddIcon />}
|
||||||
color="primary"
|
component={RouterLink}
|
||||||
startIcon={<DownloadIcon />}
|
to="/admin/products/new"
|
||||||
onClick={handleDownloadTemplate}
|
>
|
||||||
>
|
Add Product
|
||||||
Download Template
|
</Button>
|
||||||
</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
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
component={RouterLink}
|
|
||||||
to="/admin/products/new"
|
|
||||||
>
|
|
||||||
Add Product
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|
@ -701,190 +301,6 @@ 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
23
start.sh
|
|
@ -1,23 +0,0 @@
|
||||||
#!/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